Compare commits

...
2 Commits
Author SHA1 Message Date
Brues ab211962e5 unit: fix IsStealthed via UNIT_FIELD_BYTES_1 sneak flag
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.

_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.

Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
2026-07-09 11:53:58 -05:00
Brues 33a23c563c map: make GetMapOverlays overlay tiling HD-map-patch safe
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.

- Grid resolution detects the full-cell file size S (256*scale) and uses it
  as the partial-tile threshold instead of a literal 256, so HD edge tiles
  aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
  fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
  and needs no file-size guessing. Fixes overlays whose stock file was a 128
  cell (content <=128): the patch doubled them to 256, which the earlier
  S>=256 floor read as un-upscaled and halved their texcoord (the clipped
  dark patches).

Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
2026-07-09 11:18:27 -05:00
3 changed files with 145 additions and 65 deletions
+21 -17
View File
@@ -2279,7 +2279,18 @@ enum Offsets {
// Verified via the `IsAssistingRitual` development session by
// observing `0xEE00 → 0xEE05` on `/sit` over a chair (the medium-
// chair sit value 5).
//
// Byte 3 (the AnimTier / misc-flags byte, mask 0x02000000 on the
// u32) is the **stealth / sneak flag**: set while the unit is in
// the sneaking anim tier (Rogue Stealth, Druid Prowl, Shadowmeld),
// which is what drives the client's semi-transparent render of a
// stealthed unit. Verified via `_classicapi_DiffAll`: a Prowl
// toggle from a Cat-Form baseline flips byte 3 `0x00 → 0x02` while
// every other descriptor change is aura-array slot metadata — so
// this is the only standalone broadcast bit that means "stealthed".
// Read by `IsStealthed()`.
OFF_UNIT_FIELD_BYTES_1 = 0x210,
UNIT_BYTES_1_STEALTH_FLAG = 0x02000000,
// `__fastcall(void)` engine helper that refreshes the bonus
// action bar from the local player's current shapeshift form
@@ -3880,23 +3891,15 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// Player visibility/state byte field, broadcast in UpdateFields.
// Found empirically via _classicapi_DescDump (see src/debug/).
// Treated as a u32 but the meaningful bits all live in byte 0:
// 0x02 — set when the player has any visibility/form-modifying
// state active (stealth, mount, possibly shapeshift —
// set by stealth and mount in our test data).
// 0x08, 0x10 — set together when mounted (alongside 0x02).
//
// Ambiguity: bit 0x02 alone doesn't distinguish stealth from
// other visibility-modifying states. Stealth detection AND-gates
// with `mountDisplayID == 0` to exclude mount; if shapeshift
// also sets bit 0x02 we'd false-positive on druids in form
// (untested). The robust path is to walk the player's aura
// list looking for the actual stealth/prowl spell — left as
// future work if false-positives turn up.
OFF_PLAYER_FIELD_VIS_BYTES = 0x17C,
PLAYER_VIS_BIT_STEALTH = 0x02,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
// (per-slot caster-level bytes; see OFF_UNIT_FIELD_AURALEVELS above),
// and the old read only appeared to work by coincidence (a caster-
// level byte that happened to have bit 0x02 set on the test char;
// at level 60 = 0x3C it doesn't, so it read false). The real stealth
// flag is UNIT_FIELD_BYTES_1 byte 3 (UNIT_BYTES_1_STEALTH_FLAG); see
// that field above. IsStealthed reads it.
// Mount display ID — the creature display ID the engine renders
// under the mounted player. Zero when not mounted, non-zero
@@ -4650,6 +4653,7 @@ enum Offsets {
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
OFF_SPELL_RECORD_MECHANIC = 0x14,
SPELL_AURA_MOD_STEALTH = 16,
SPELL_AURA_MOD_SHAPESHIFT = 36,
SPELL_AURA_MOUNTED = 78,
+76 -17
View File
@@ -62,6 +62,19 @@
// BLP1 and BLP2 keep width/height at header offsets +0x0C/+0x10.
// Resolution runs once per overlay per session (cached).
//
// ## HD map patches (uniform tile upscale)
//
// A custom `patch-*.mpq` can replace the overlay art with higher-res tiles
// (every 256px tile re-exported at 256*scale). Because we read the *file*
// dimensions, that upscale must not be assumed to be 256: the resolver
// detects the full-cell size `S` (the largest tile dimension = 256*scale)
// and expresses both the partial-tile threshold and the texcoords relative
// to it. Draw sizes/positions remain in logical map pixels (the patch changes
// texture resolution, not the map canvas), so an interior tile reads texcoord
// 1.0 at any scale and stock data (S == 256) is byte-identical to before.
// The upscale factor need not be integral; only a uniform per-overlay scale
// is assumed.
//
// ## Return shape
//
// Array of overlay tables:
@@ -102,6 +115,17 @@ using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
constexpr int kMaxTiles = 64;
// Smallest power of two >= v (min 16), matching the engine's own
// WorldMapFrame tile-file-size loop. A tile's content fills content/NextPot
// of its (power-of-two) file — a fraction that's identical whether the file
// is stock or HD-upscaled, so texcoords derived from it are scale-invariant.
int NextPot(int v) {
int p = 16;
while (p < v)
p <<= 1;
return p;
}
// Resolved tile grid for one overlay, cached for the session (DBC and
// MPQ contents are immutable per process).
struct ResolvedOverlay {
@@ -109,6 +133,7 @@ struct ResolvedOverlay {
uint8_t rows;
uint8_t count; // tiles to draw (foreign tail already dropped)
bool upscaled;
uint16_t cell; // full-cell file size (256 on stock, 256*scale on an HD patch)
uint16_t fileW[kMaxTiles]; // BLP file dimensions per tile
uint16_t fileH[kMaxTiles];
};
@@ -161,13 +186,29 @@ void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
if (dbcRows < 1)
dbcRows = 1;
// Full-cell file size. On stock data a full (interior) tile is 256px; an
// HD map patch (patch-*.mpq) upscales every tile uniformly, so a full cell
// is 256*scale. Full tiles are the largest; edges/slivers are smaller — so
// the max tile dimension is the full-cell size. Partial-tile detection
// (and the texcoords in PushTiles) must compare against this S, not a
// hardcoded 256, or an HD edge tile (e.g. a 200px edge upscaled to 400px)
// reads as "full" and shears the row-major grid into diagonal strips.
int S = 256;
for (int i = 0; i < n; ++i) {
if (out->fileW[i] > S)
S = out->fileW[i];
if (out->fileH[i] > S)
S = out->fileH[i];
}
out->cell = static_cast<uint16_t>(S);
// Real column count: the first partial-width tile is a row's right
// edge. Search one past dbcCols so a sliver column the DBC width
// rounds off is still found.
int cols = dbcCols;
bool widthEdge = false;
for (int i = 0; i < n && i <= dbcCols; ++i) {
if (out->fileW[i] < 256) {
if (out->fileW[i] < S) {
cols = i + 1;
widthEdge = true;
break;
@@ -179,7 +220,7 @@ void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
int rows = dbcRows;
bool heightEdge = false;
for (int i = 0; i < n; ++i) {
if (out->fileH[i] < 256) {
if (out->fileH[i] < S) {
rows = i / cols + 1;
heightEdge = true;
break;
@@ -304,27 +345,45 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
posX = offX + col * drawW;
posY = offY + row * drawH;
} else {
// Canonical 256px grid. Edge tiles draw their content pixels:
// from the DBC remainder when it's honest, else (sliver
// column the DBC rounded away) the whole file.
// Canonical 256px logical grid. Draw sizes/positions stay in
// logical map pixels; the texcoord is the content fraction of the
// (possibly HD-upscaled) file. `scale` = file px per logical px
// (1 on stock, 2/4 on an HD patch), so a full interior tile reads
// texCoord 1.0 regardless of file size, and an edge's content
// (remainder * scale) is measured against its actual file width.
const int fileW = r->fileW[i];
const int fileH = r->fileH[i];
int pixW = 256;
int pixH = 256;
const double scale = (r->cell > 0) ? (r->cell / 256.0) : 1.0;
double logicalW = 256.0, logicalH = 256.0;
tcX = 1.0;
tcY = 1.0;
if (col == r->cols - 1) {
pixW = dbcW - 256 * (r->cols - 1);
if (pixW <= 0 || pixW > fileW)
pixW = fileW;
const int rem = dbcW - 256 * (r->cols - 1);
if (rem <= 0) {
// Sliver the DBC width rounded away: whole file is content
// (its logical width is the file width de-scaled).
logicalW = fileW / scale;
} else {
// Content fills rem/NextPot(rem) of the POT tile file.
// Scale-invariant, so an HD-upscaled file (POT*scale)
// reads the same fraction — using the raw file width here
// would halve the texcoord on any tile the patch doubled.
logicalW = rem;
tcX = rem / static_cast<double>(NextPot(rem));
}
}
if (row == r->rows - 1) {
pixH = dbcH - 256 * (r->rows - 1);
if (pixH <= 0 || pixH > fileH)
pixH = fileH;
const int rem = dbcH - 256 * (r->rows - 1);
if (rem <= 0) {
logicalH = fileH / scale;
} else {
logicalH = rem;
tcY = rem / static_cast<double>(NextPot(rem));
}
}
drawW = pixW;
drawH = pixH;
tcX = static_cast<double>(pixW) / fileW;
tcY = static_cast<double>(pixH) / fileH;
drawW = logicalW;
drawH = logicalH;
posX = offX + col * 256;
posY = offY + row * 256;
}
+48 -31
View File
@@ -46,6 +46,34 @@ uint32_t MountDisplayID(const uint8_t *desc) {
desc + Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
}
// Scans the player's helpful-aura (buff) slots for the first spell whose
// Spell.dbc record lists `auraEffect` (a `SPELL_AURA_*` code) in any of
// its three effect slots, and returns that spellID (0 if none). Used by
// `Dismount` to find the active mount aura to cancel — the engine's
// `CMSG_CANCEL_AURA` needs the spellID, not a slot index.
uint32_t FindPlayerBuffWithEffect(int auraEffect) {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] == auraEffect)
return spellID;
}
}
return 0;
}
// `IsMounted()` — true iff the player is currently mounted. Reads
// `UNIT_FIELD_MOUNTDISPLAYID` from the descriptor; non-zero means
// the engine is rendering a mount under the player.
@@ -71,46 +99,35 @@ int __fastcall Script_Dismount(void *L) {
if (MountDisplayID(desc) == 0)
return 0;
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] != Offsets::SPELL_AURA_MOUNTED)
continue;
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
return 0;
}
const uint32_t spellID = FindPlayerBuffWithEffect(Offsets::SPELL_AURA_MOUNTED);
if (spellID != 0) {
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
}
return 0;
}
// `IsStealthed()` — true iff the player is currently stealthed
// (Rogue Stealth / Druid Prowl). Checks `PLAYER_FIELD_VIS_BYTES`
// bit 0x02. Mount also sets that bit, so we AND-gate with
// `mountDisplayID == 0` to disambiguate. See the comment on
// `OFF_PLAYER_FIELD_VIS_BYTES` in Offsets.h for the full bit map
// and known limitations (untested for shapeshift forms).
// (Rogue Stealth / Druid Prowl / Night Elf Shadowmeld / etc.). Reads
// the sneak flag in `UNIT_FIELD_BYTES_1` byte 3 (mask
// `UNIT_BYTES_1_STEALTH_FLAG`) — the same broadcast bit the client's
// render path uses to draw a stealthed unit semi-transparent.
//
// Derived via `_classicapi_DiffAll`: a Prowl toggle from a Cat-Form
// baseline flips byte 3 `0x00 -> 0x02` and touches nothing else but
// aura-array slot metadata, so this is the one standalone descriptor
// bit that means "stealthed" (the earlier `0x17C & 0x02` read was
// actually AURALEVELS and only worked by coincidence). Being a unit
// anim-tier flag rather than a class field, it covers every stealth
// source and rank without a spellID list or an aura scan.
int __fastcall Script_IsStealthed(void *L) {
auto *desc = Unit::Identity::PlayerDescriptor();
bool stealthed = false;
if (desc != nullptr) {
const uint32_t visBytes = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_PLAYER_FIELD_VIS_BYTES);
stealthed = (visBytes & Offsets::PLAYER_VIS_BIT_STEALTH) != 0
&& MountDisplayID(desc) == 0;
const uint32_t bytes1 = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_BYTES_1);
stealthed = (bytes1 & Offsets::UNIT_BYTES_1_STEALTH_FLAG) != 0;
}
Game::Lua::PushBool(L, stealthed);
return 1;