Document the paper-doll loader, its format law, and art-replacement checks

The guide covered authoring and coordinates but not the code that consumes the
result, which is where the constraints actually come from. Adds what an agent
or a human needs to work on this system without rediscovering it.

New: "How the runtime loads and draws the art". The call chain from
huddamage.cpp texturename[] through CMFD_Device::LoadDamageTexture /
CRadar_Device::LoadRadarDamageTexture / CMR_Device::LoadMRTargetTexture into
CreateATextureFromBitmap, with the path each device builds.

The important part is the format law. CreateATextureFromBitmap uses the raw
palette INDEX as both brightness and alpha and never looks the palette up:

    wA = data[x];
    WORD wBit = (WORD)((wA<<8)&0xF000|0x0FFF);
    *bits++ = (WORD)((wA>0)?wBit:0);

So a doll must be 8-bit with an identity greyscale palette, alpha is the top
nibble (index < 16 invisible, 0 dropped), and >8bpp is unsupported. A file can
look perfect in a viewer and still be wrong: the Battlemaster radar art arrived
with a 131-entry optimised palette running opposite to brightness, which would
have rendered near-black and mostly transparent. The rule explicitly does NOT
extend to hsh/MFD tiles or hsh/Mechs portraits, which load through GDI.

Audited every doll the code actually references rather than the directory:
65/65 correct on both displays, the only absence being the commented-out Dasher
entry at huddamage.cpp:81, which pairs with its commented-out coord.cpp rows.
Scoping matters -- hsh/hud holds ~197 files but only ~65 are dolls, so a
folder-wide audit produces 130 false positives.

New: "Replacing art for an existing chassis" -- per-zone bounds, reassembly,
and spill. Re-shading inside an unchanged box is safe; a moved piece silently
scrambles the doll and is invisible until something takes damage in game.

Also records hsh/mr_texture.bmp as the game's only chassis-independent doll,
and that hudchat.cpp:853's claim that it holds no image is wrong.

Co-authored-by: Claude Opus 5 (Anthropic) <noreply@anthropic.com>
Co-authored-by: GitHub Copilot <copilot@github.com>
This commit is contained in:
2026-08-09 20:45:13 -05:00
co-authored by Claude Opus 5 GitHub Copilot
parent 877d1b8cfa
commit 777f338c31
+151 -1
View File
@@ -18,8 +18,17 @@ This guide is about the last two displays, both controlled by `coord.cpp`. The n
comments refer to the external display. The normal cockpit HUD has different coordinates in
`huddamage.cpp`.
A fourth consumer shares the MFD art without having its own table: the cameraship target display
(`CMR_Device::LoadMRTargetTexture`) reads the same `hsh/hud/<name>.bmp` files. Changing MFD art
therefore changes what the cameraship shows. Its own generic doll is a separate asset -- see "The
generic fallback doll" below.
The small images in `hsh/MFD/` are also a different asset set. `render.cpp` tiles those images
into a mech texture atlas. They are not the 512x512 damage-mask images mapped by `coord.cpp`.
into a mech texture atlas. They are not the 512x512 damage-mask images mapped by `coord.cpp`, and
they are **not** bound by the greyscale format law that governs the dolls.
Start with "How the runtime loads and draws the art" if you are new to this system: the file
format is dictated by the loader, and the constraint is invisible in the artwork itself.
## Controlling code
@@ -63,6 +72,98 @@ Use `{0,0,0,0}` and `{0,0}` for a zone that has no art. The comment above `offse
says `S2 S1`; the array consumers and the other tables use index 9 as S1 and index 10 as S2.
Treat the header comment as stale and preserve the index order above.
## How the runtime loads and draws the art
Both displays follow the same path. `huddamage.cpp` owns the name table and calls the loaders
once per mech, passing a stem that already carries the `hud\` prefix:
```text
huddamage.cpp:55 const char *texturename[LastMechID+1] = { "hud\\annihilator", ... "hud\\zeus" }
huddamage.cpp:414 mfd_device.LoadDamageTexture (texturename[m_MechID])
huddamage.cpp:415 radar_device.LoadRadarDamageTexture(texturename[m_MechID])
huddamage.cpp:2054 radar_device.LoadRadarDamageTexture(texturename[m_TargetMechID])
```
Each device prepends its own root:
| Device | Function | Path built | Resolves to |
|---|---|---|---|
| External MFD | `CMFD_Device::LoadDamageTexture`, render.cpp:2073 | `hsh\<stem>.bmp` | `hsh\hud\<name>.bmp` |
| Radar | `CRadar_Device::LoadRadarDamageTexture`, render.cpp:1648 | `hsh\radar\<stem>.bmp` | `hsh\radar\hud\<name>.bmp` |
| Cameraship target | `CMR_Device::LoadMRTargetTexture`, render.cpp:1403 | `hsh\<stem>.bmp` | `hsh\hud\<name>.bmp` |
All three call `CreateATextureFromFile` -> `CreateATextureFromBitmap` (render.cpp:412).
Note the entry commented out at `huddamage.cpp:81` (`hud\\dasher`), which pairs with the
commented-out Dasher rows in `coord.cpp`. Keep those two in step: if Dasher is ever activated,
both must be uncommented together, and the art must exist for **both** displays.
### The format law: 8-bit identity greyscale, and why it is not negotiable
This is the single hardest constraint in the whole system, and nothing warns you when it is
broken. `CreateATextureFromBitmap` is the entire reason:
```cpp
wA = data[x]; // the raw palette INDEX, not a colour
WORD wBit = (WORD)((wA<<8)&0xF000|0x0FFF); // top nibble of that index -> alpha
*bits++ = (WORD)((wA>0)?wBit:0); // index 0 -> discarded entirely
```
It never looks the palette up. The byte stored in the BMP **is** the pixel value, used as both
brightness and alpha. Consequences that govern every doll:
- The file must carry an **identity greyscale palette**: entry N must be RGB(N,N,N). Pillow mode
`L` produces exactly that. Mode `P` with an optimised palette does not, even if every entry in
it happens to be grey.
- Alpha is the **top nibble**, so anything below index 16 is effectively invisible and index 0 is
dropped outright. Faint anti-aliased edges will not render.
- More than 8 bits per pixel is not supported on this path at all.
A file can look perfect in an image viewer and still be wrong here. A real example: a supplied
Battlemaster radar doll carried a 131-entry optimised greyscale palette whose order ran roughly
opposite to brightness. Index 1 was pure white in the palette, but the engine reads index 1 as
level 1 -- near black, alpha nibble 0, invisible. Peak brightness would have rendered at 129/255
with alpha capped at 8 of 15. Broken, not subtly off.
Check before installing anything:
```python
from PIL import Image
im = Image.open(path)
assert im.mode == "L" # or every palette entry N must equal (N, N, N)
assert im.size == (512, 512)
```
Convert with `Image.open(src).convert("L").save(dst)`, which resolves each index through the
palette, then confirm the converted pixels equal the supplied artwork.
**Do not generalise this rule.** `hsh/MFD/` atlas tiles, `hsh/Mechs/` score-sheet portraits
(recscore.cpp:2959) and the map images load through Win32 `LoadImage` + `BitBlt` or
`DrawBitmapToSurface`, all of which honour the palette. Those directories legitimately hold a
mix of `L`, `P` and `RGB`, and normalising them would be pointless churn.
### Auditing the whole set
Scope the audit to the stems in `texturename[]`, not to the directory. `hsh/hud/` holds around
197 files but only the ~65 doll images are subject to the format law; the rest are other HUD art
at other sizes on other code paths, and auditing the folder produces a flood of false alarms.
For each stem, check `hsh/<stem>.bmp` and `hsh/radar/<stem>.bmp` for: exists (case-insensitively),
512x512, 8bpp, mode `L` or an identity palette. Whole-tree state as of 2026-08-09: **65/65 correct
on both displays**, the only absence being the commented-out Dasher entry.
### The generic fallback doll
`hsh/mr_texture.bmp` (shaded, 126 grey levels) and `hsh/mr_texturea.bmp` (mask, 29 levels) hold a
generic articulated mech -- head, centre and side torsos, both arms with gun ports, both legs --
in their top-left corner. `CMR_Device` loads them at render.cpp:1276-1277 for the cameraship
Map/Armour screen. The mech occupies columns 0-55; a shield glyph sits at 66-78.
It is the only chassis-independent paper doll in the game, so it is the right starting point for
any generic or placeholder doll. Note that `hudchat.cpp:853` claims *"there is no actual image on
mr_texture.bmp & mr_texturea.bmp"* -- that comment is wrong, and is probably why the asset stayed
unnoticed.
## What the values mean
**This is the single most important section. Getting it backwards silently breaks every row,
@@ -390,6 +491,42 @@ Loose `hsh` art is not packed into a `.mw4` resource for this path, so an actual
does not require `build-resources.ps1`. Coordinate changes still require rebuilding the game
executable because `DXRasterizer.cpp` includes `coord.cpp` directly.
## Replacing art for an existing chassis
New artwork for a chassis that already has rows in `coord.cpp` must fit the rows that are already
there. If a piece moved, the reassembled doll comes apart -- and nothing warns you, because the
engine will happily crop from the wrong place and draw the result. The failure is invisible until
someone takes damage in game.
Run three checks against the **current** runtime file before installing a replacement. All three
are cheap and none needs the game.
1. **Per-zone bounds.** For each zone, crop the `texuv` rectangle from both the old and the new
sheet and take the bounding box of lit pixels inside that crop. The boxes must agree within a
pixel or two.
- identical boxes, lower IoU -> the piece was re-shaded in place. Fine.
- shifted box -> the piece moved. The row must be re-measured or the art re-exported.
2. **Reassembly.** Do exactly what the engine does: crop each `texuv` rect and paste it at its
`offset`. Compare the assembled silhouettes. Coordinate-compatible art scores above about
0.95 IoU with an assembled bounding box within a couple of pixels. This is the check that
actually answers "do the coordinates still map", because it exercises both tables together.
3. **Spill.** Count lit pixels falling outside every `texuv` rectangle; those get clipped. Then
look at their grey levels before reacting:
- below roughly 60 -> anti-aliasing halo, invisible once the alpha nibble is applied.
- bright pixels outside a box -> real artwork is being cut off, and the rectangle needs
widening rather than the art being accepted as-is.
A worked result, replacing the Battlemaster radar doll: every zone's bounding box matched, the
three lowest per-zone IoUs (0.867-0.915) were re-shading inside unchanged boxes, assembled IoU was
0.9676 with bbox `(91,4,370,397)` vs `(92,6,370,397)`, and all 81 spilled pixels were faint (max
level 59, none above 128). Verdict: coordinate-compatible, no `coord.cpp` change needed. The
visible difference was an intentional new gun barrel on the left arm, which sits inside its box.
Do this **before** installing, not after. Also confirm the replacement satisfies the format law
above; a supplied file is far more likely to have a palette problem than a geometry problem.
## Reproducible validation
> **`generate_comparison_maps.py` cannot detect the failures that actually occurred.** It compares
@@ -740,4 +877,17 @@ art; confirm it on a physical MFD before repeating the technique on other chassi
- Editing `MW4/hsh` instead of the source tree `Gameleap/mw4/hsh`.
- Writing a runtime BMP to a lower-cased path. Several destinations are capitalised on this
case-sensitive checkout (`Atlas.bmp`, `Fafnir.bmp`); resolve the existing filename first.
- Saving a doll as indexed or optimised colour instead of 8-bit greyscale. The loader uses the
palette index as the pixel value, so anything but an identity greyscale palette renders wrong,
usually inverted and mostly transparent. Looking right in an image viewer proves nothing.
- Applying that greyscale rule to `hsh/MFD` tiles or `hsh/Mechs` portraits. Those load through
GDI, which honours the palette, and are legitimately a mix of `L`, `P` and `RGB`.
- Auditing the `hsh/hud` directory rather than the stems named in `texturename[]`. Most files in
that folder are other HUD art at other sizes, and folder-wide checks drown in false positives.
- Installing replacement art for an existing chassis without reassembling it first. Re-shading
inside a box is safe; a moved piece silently scrambles the doll.
- Relying on faint edge detail. Alpha is the top nibble of the pixel value, so anything below
index 16 is invisible and index 0 is dropped.
- Activating a mech in `coord.cpp` without also uncommenting its `texturename[]` entry, or
supplying art for only one of the two displays.
- Testing piece collisions with bounding boxes. These silhouettes are concave; use pixel masks.