UO-style binary asset container for human art (.idx + .bin) #2

Closed
opened 2026-07-16 22:43:54 +02:00 by marco · 2 comments
Owner

Replace the loose per-frame PNGs in assets/human/ with our own indexed binary container, in the spirit of Ultima Online's anim.mul + anim.idx. Follow-up carved out of #1 (MonoGame migration) to keep that issue focused.

Why

The MonoGame client currently loads human sprites as loose PNGs ({action}_{dir}_{i}.png) via Texture2D.FromStream at runtime. That works, but:

  • Per-frame anchors are lost. Today the client uses a single hardcoded anchor (HumanArt.AnchorX=64, AnchorY=120) for every frame. Legacy UO art carries a real draw offset per frame; discarding it is a latent sprite-alignment bug. A container can store the true anchor alongside each frame.
  • Runtime PNG decode + hundreds of loose files. A raw-pixel container removes the PNG decode step (new Texture2D(w,h); SetData(pixels)), ships as one atomic file, and is deterministic across platforms.
  • It matches how UO actually stores art (idx = offset/length table, mul = pixel payload).

This is intentionally NOT PNGs concatenated together — the point is raw pixel blocks + a metadata index, so no image codec runs at runtime.

Prerequisite from #1

The MonoGame client loads human art behind a small interface (asset loader / IHumanArtSource), so the container can slot in without touching rendering. Verify that seam exists before starting.

Scope

  • Define the container format: header (magic + version), an index of entries keyed by (action, direction, frame) each carrying {offset, length, width, height, anchorX, anchorY}, and a payload of raw RGBA (MonoGame Color order) pixel blocks. Decide single-file vs .idx+.bin, and whether payload is Deflate-compressed.
  • Add a --pack mode to tools/IsoMmo.AssetExtractor that writes the container from the legacy anim.mul, capturing the real per-frame draw offset as the anchor. Keep (or gate) a PNG-export mode for inspection/debugging.
  • New container reader in the client that implements the existing asset-loader interface: read the index once, build one Texture2D per frame via SetData, expose per-frame anchor to the renderer.
  • Renderer uses the per-frame anchor from the container instead of the hardcoded AnchorX/AnchorY constants.
  • Placeholder-diamond fallback still triggers when the container is absent (repo stays shippable without extracted assets; container stays git-ignored like the PNGs).
  • Unit tests for the format: round-trip (pack then read back identical pixels + anchors), index lookup, truncated/corrupt-file handling, and missing-file → fallback.

Out of scope

Server, protocol, auth, DB. Any art beyond the human body already handled by the extractor.

Definition of Done

Delta beyond the base DoD (tests green, whole solution builds, zero warnings, multi-platform preserved):

  • Running the extractor in --pack mode against a real D:\Games\UO install produces the container, and the client renders human sprites from it in all 8 directions with correct facing/mirroring — visually indistinguishable from the loose-PNG path it replaces.
  • Sprite alignment is demonstrably driven by per-frame anchors from the container (not the old hardcoded constants): a frame whose legacy offset differs from (64,120) lands in the visually correct position, and this is called out in the verification notes.
  • With no container present, the client falls back to placeholder diamonds and logs a warning — same behaviour as today with missing PNGs.
  • A pack → read round-trip unit test proves pixels and anchors survive a write/read cycle byte-for-byte, and a corrupt/truncated container is rejected without crashing the client.
  • The container format (header, index entry layout, endianness, pixel order, compression) is documented in the extractor and/or README so a reader can be reimplemented from the doc alone.
  • No runtime image-codec dependency is used to load human art (loading is raw SetData, not Texture2D.FromStream).
Replace the loose per-frame PNGs in `assets/human/` with our own indexed binary container, in the spirit of Ultima Online's `anim.mul` + `anim.idx`. Follow-up carved out of #1 (MonoGame migration) to keep that issue focused. ## Why The MonoGame client currently loads human sprites as loose PNGs (`{action}_{dir}_{i}.png`) via `Texture2D.FromStream` at runtime. That works, but: - **Per-frame anchors are lost.** Today the client uses a single hardcoded anchor (`HumanArt.AnchorX=64`, `AnchorY=120`) for every frame. Legacy UO art carries a real draw offset per frame; discarding it is a latent sprite-alignment bug. A container can store the true anchor alongside each frame. - **Runtime PNG decode + hundreds of loose files.** A raw-pixel container removes the PNG decode step (`new Texture2D(w,h); SetData(pixels)`), ships as one atomic file, and is deterministic across platforms. - It matches how UO actually stores art (idx = offset/length table, mul = pixel payload). This is intentionally NOT PNGs concatenated together — the point is raw pixel blocks + a metadata index, so no image codec runs at runtime. ## Prerequisite from #1 The MonoGame client loads human art behind a small interface (asset loader / `IHumanArtSource`), so the container can slot in without touching rendering. Verify that seam exists before starting. ## Scope - [ ] Define the container format: header (magic + version), an index of entries keyed by `(action, direction, frame)` each carrying `{offset, length, width, height, anchorX, anchorY}`, and a payload of raw RGBA (MonoGame `Color` order) pixel blocks. Decide single-file vs `.idx`+`.bin`, and whether payload is Deflate-compressed. - [ ] Add a `--pack` mode to `tools/IsoMmo.AssetExtractor` that writes the container from the legacy anim.mul, capturing the real per-frame draw offset as the anchor. Keep (or gate) a PNG-export mode for inspection/debugging. - [ ] New container reader in the client that implements the existing asset-loader interface: read the index once, build one `Texture2D` per frame via `SetData`, expose per-frame anchor to the renderer. - [ ] Renderer uses the per-frame anchor from the container instead of the hardcoded `AnchorX/AnchorY` constants. - [ ] Placeholder-diamond fallback still triggers when the container is absent (repo stays shippable without extracted assets; container stays git-ignored like the PNGs). - [ ] Unit tests for the format: round-trip (pack then read back identical pixels + anchors), index lookup, truncated/corrupt-file handling, and missing-file → fallback. ## Out of scope Server, protocol, auth, DB. Any art beyond the human body already handled by the extractor. ## Definition of Done Delta beyond the base DoD (tests green, whole solution builds, zero warnings, multi-platform preserved): - [ ] Running the extractor in `--pack` mode against a real `D:\Games\UO` install produces the container, and the client renders human sprites from it in all 8 directions with correct facing/mirroring — visually indistinguishable from the loose-PNG path it replaces. - [ ] Sprite alignment is demonstrably driven by per-frame anchors from the container (not the old hardcoded constants): a frame whose legacy offset differs from `(64,120)` lands in the visually correct position, and this is called out in the verification notes. - [ ] With no container present, the client falls back to placeholder diamonds and logs a warning — same behaviour as today with missing PNGs. - [ ] A pack → read round-trip unit test proves pixels and anchors survive a write/read cycle byte-for-byte, and a corrupt/truncated container is rejected without crashing the client. - [ ] The container format (header, index entry layout, endianness, pixel order, compression) is documented in the extractor and/or README so a reader can be reimplemented from the doc alone. - [ ] No runtime image-codec dependency is used to load human art (loading is raw `SetData`, not `Texture2D.FromStream`).
Author
Owner

Design agreed — single-file .isoa container (manifest + raw-pixel blob)

Generalizing this beyond human art to all sprite assets (human, creatures, statics, land).

Format [magic "ISOA"][version:int][manifestLen:int][manifest JSON utf8][raw-pixel blob] — glTF-style: inspectable JSON metadata + a binary buffer of raw RGBA32 pixels (no runtime image codec, per the original reasoning). Manifest = named animations, each { name, fps, frames:[{offset,length,width,height,anchorX,anchorY}] }; a static/land tile is a 1-frame animation. Per-frame anchors are preserved (fixes the current single-hardcoded-anchor latent bug). Versioned header so a stale pack from a teammate fails cleanly, not silently.

Library IsoMmo.Assets (pure .NET, no MonoGame dep): AssetPack model + AssetPackFile.Read/Write. Referenced by client, extractor and the editor. Round-trip unit-tested.

Integration: the extractor writes .isoa packs (it already decodes true per-frame anchors — AnimFrame.CenterX/Y — currently discarded); the client's Human/Static/Land loaders read from packs; the loose-PNG path is removed. Rendering starts honoring per-frame anchors (visible alignment change to verify).

A pack of UO-derived pixels is still not committable (repackaging ≠ ownership). Packs stay git-ignored and each dev regenerates from their own licensed client. A pack becomes committable/shareable only once it holds our own original art — which the editor (#NEW) enables.

Definition of Done (delta)

  • AssetPackFile round-trips a pack (metadata + anchors + exact pixels) in a unit test; a bad magic/version is rejected with a clear error.
  • Extractor emits .isoa packs for human, creatures, statics and land; just extract-all produces them.
  • Client renders from packs (no loose PNGs) and honors per-frame anchors; with no pack present it still falls back to placeholders and runs.
### Design agreed — single-file `.isoa` container (manifest + raw-pixel blob) Generalizing this beyond human art to all sprite assets (human, creatures, statics, land). **Format** `[magic "ISOA"][version:int][manifestLen:int][manifest JSON utf8][raw-pixel blob]` — glTF-style: inspectable JSON metadata + a binary buffer of raw RGBA32 pixels (no runtime image codec, per the original reasoning). Manifest = named animations, each `{ name, fps, frames:[{offset,length,width,height,anchorX,anchorY}] }`; a static/land tile is a 1-frame animation. **Per-frame anchors are preserved** (fixes the current single-hardcoded-anchor latent bug). Versioned header so a stale pack from a teammate fails cleanly, not silently. **Library** `IsoMmo.Assets` (pure .NET, no MonoGame dep): `AssetPack` model + `AssetPackFile.Read/Write`. Referenced by client, extractor and the editor. Round-trip unit-tested. **Integration**: the extractor writes `.isoa` packs (it already decodes true per-frame anchors — `AnimFrame.CenterX/Y` — currently discarded); the client's Human/Static/Land loaders read from packs; the loose-PNG path is removed. Rendering starts honoring per-frame anchors (visible alignment change to verify). ### Copyright note (unchanged) A pack of UO-derived pixels is still not committable (repackaging ≠ ownership). Packs stay git-ignored and each dev regenerates from their own licensed client. A pack becomes committable/shareable only once it holds our own original art — which the editor (#NEW) enables. ### Definition of Done (delta) - `AssetPackFile` round-trips a pack (metadata + anchors + exact pixels) in a unit test; a bad magic/version is rejected with a clear error. - Extractor emits `.isoa` packs for human, creatures, statics and land; `just extract-all` produces them. - Client renders from packs (no loose PNGs) and honors per-frame anchors; with no pack present it still falls back to placeholders and runs.
Author
Owner

Container part done and merged to main (e0f61f0). IsoMmo.Assets library + .isoa format (versioned magic+manifest+raw-RGBA blob, per-frame anchors, round-trip tested); extractor writes packs for human/creatures/statics/land (just extract-all); client loads them (per-frame anchors, placeholder fallback); loose-PNG shipping path removed. 161 tests green. Remaining under this issue was human-only; generalized to all categories. The graphical editor is tracked separately in #21.

Container part done and merged to main (e0f61f0). IsoMmo.Assets library + .isoa format (versioned magic+manifest+raw-RGBA blob, per-frame anchors, round-trip tested); extractor writes packs for human/creatures/statics/land (just extract-all); client loads them (per-frame anchors, placeholder fallback); loose-PNG shipping path removed. 161 tests green. Remaining under this issue was human-only; generalized to all categories. The graphical editor is tracked separately in #21.
marco closed this issue 2026-07-17 12:05:17 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#2
No description provided.