claude_ut2k4

UT2004 DM Map Project — Progress & Roadmap

A living “save state” for the project. Read this together with CLAUDE.md, which holds the durable context (goal, file-format essentials, architecture decision). This document tracks where we are and what’s next.

Last updated: 2026-06-11


Step 7 — Full-map replication (DONE ✅✅✅)

replica/DM-Rankin-Replica.ut2 exists (5.7 MB), saved from UnrealEd, validated.

The full pipeline ran end-to-end: binary decode → saveable T3D → UnrealEd Import → Build All → Save As. Verification:

Saveable-mode lessons (now encoded in --t3d-saveable):

  1. RF_Public separates assets (keep: StaticMesh/Texture/Shader) from private instances (drop: StaticMeshInstance/ConvexVolume; ReachSpec + Screenshot/Summary by name).
  2. The Brush= line must NOT be dropped (Actors(1)->Brush!=NULL assert) — rewrite it to Model'MyLevel.<name>' so it binds the inline polygon block locally.
  3. ALL kept refs to emitted actors / inlined sub-objects must be qualified MyLevel.<name> (the importer binds source-package-qualified names to the LOADED original — MyLift, Base, Touching leaked this way and blocked the save).
  4. Region= is dropped (zones recomputed on Build Geometry). The replica legitimately imports the original package’s PUBLIC embedded assets (StaticMesh/Shader/Texture/Sound) — same dependency model the community uses.

Original Step-7 record (for history)

Goal: replicate DM-Rankin.ut2 end-to-end through our pipeline.

Done:

BLOCKER — cannot write the replica .ut2 to disk. MAP SAVE fails with “Couldn’t save package - maybe file is read-only” (reproduced to two writable folders; not a filesystem perm issue — verified C:\UT2004\Maps is writable). Editor.log root cause: our faithful T3D references the original map’s private/regenerable objects by their DM-Rankin.* path — StaticMeshInstance (680, regenerated on Build), ReachSpec via PathList/UpstreamPaths (909, rebuilt by Build Paths), the explicit Brush=Model'DM-Rankin.ModelN' names (554), Screenshot/Summary/ZoneEffect/AntiPortal. Because the original DM-Rankin.ut2 must stay loaded to supply its embedded meshes/textures (shared-asset refs like StaticMesh/ Shader/Texture, which are legitimately fine), these private refs bind cross-package and UnrealEd refuses to save a map that references another package’s private objects.

Fix — APPROVED by user, NOT yet implemented (immediate next task): add a “portable/saveable” T3D mode that omits refs to engine-regenerated/private map-local objects (StaticMeshInstance, PathList/UpstreamPaths, the Brush=Model line, Screenshot/Summary/ZoneEffect/AntiPortal) while keeping legitimate shared-asset imports (StaticMesh/Shader/Texture). The engine regenerates the omitted objects on Build Geometry/Lighting/Paths. Trade-off: diverges from byte-identical (intentionally) to produce a re-importable, saveable map.

Step 8 — Map-inventory pattern analysis (DONE ✅)

Goal-part 2: mine the 39 maps for repeatable bot-pathing / inventory patterns.

Step 9 — Procedural test-map skeleton (paste confirmed working — proceeding to Build Geometry/Lighting/Paths)

First from-scratch generation (not decoded from a stock map): gen_testmap.py emits a paste-ready T3D — one CSG_Subtract room shell, a CSG_Add centre platform, 5 ceiling Lights, 4 PlayerStarts, 4 PathNodes, and 4 pickup/InventorySpot pairs (health, ammo, adrenaline, weapon) following the Step-8 anchoring rules. Output: import_kit/TestMap-Skeleton.t3d + import_kit/TestMap-Skeleton-README.md (import/build/ verification steps). The box-brush polygon template (face order, normals, winding, texture axes) was reverse-engineered from two real working brushes (DM-Gael Brush9, DM-Rankin Brush340) and confirmed identical for CSG_Add/CSG_Subtract.

v1 pasted into UnrealEd -> immediate GPF (USkeletalMeshInstance::Render <- FDynamicActor::Render <- RenderLevel). Root cause: v1 placed the weapon pickup as a bare Begin Actor Class=ShockRifle (a Weapon, which carries a skeletal view-mesh) — rendering that mesh in the editor viewport outside normal pawn-spawn init crashes the renderer. No stock map places weapons this way.

Fix (v2): weapon pickup is now an xWeaponBase “charger” (StaticMesh'2k4ChargerMeshes.ChargerMeshes.WeaponChargerMesh-DS', WeaponType=Class'XWeapons.ShockRifle') — the pattern every stock DM map actually uses (confirmed via DM-Rankin’s xWeaponBase1). Its InventorySpot links back via myPickupBase instead of markedItem.

v2 was initially reported to crash identically, which prompted a diagnostic --minimal variant of gen_testmap.py (import_kit/TestMap-Skeleton-Minimal.t3d — same brushes/lights/PlayerStarts/PathNodes, but zero pickups/InventorySpots/weapon charger, 15 actors vs. 23) for staged repro testing.

Resolution (confirmed): re-testing in UnrealEd, both TestMap-Skeleton-Minimal.t3d (15 actors) and the full TestMap-Skeleton.t3d (23 actors, including the v2 xWeaponBase charger) paste and redraw with no GPF. The v2 fix is confirmed correct; the second crash report did not reproduce. Tracked in issue #7 (closed). The --minimal variant is kept as a reusable diagnostic tool for any future paste-crash triage. Next step: Build Geometry -> Build Lighting -> Build Paths -> Play, per the verification checklist in TestMap-Skeleton-README.md (same as Step 5/7 validated the decode-side T3D).


The goal (recap)

Read, interpret, and ultimately reconstruct official UT2004 Deathmatch maps, with the longer-term aim of programmatically generating/modifying them. Scope is the stock maps that shipped with the game — not custom community content.

Architecture (from CLAUDE.md): read binary, write via T3D.

  1. Read the binary .ut2 directly in Python.
  2. Write changes as plain-text T3D (UnrealEd’s export format).
  3. Finalize in UnrealEd: Build Geometry → Build Lighting → save .ut2.

Component detail — Steps 1–6 (read/decode/T3D foundation)

(Live status is at the top: Step 7 replication and Step 8 pattern analysis are both DONE. The sections below document the foundation those build on.)

Step 6 — brush/BSP geometry decoded ✅

The full read → decode → generate-T3D → engine-import loop is proven (Step 5), and we now reconstruct brush geometry — the part CLAUDE.md flagged as “hard / not fully public”. Editor-brush polygons decode from the binary Model → Polys → FPoly chain and emit as Begin Brush … Begin Polygon … blocks that are byte-identical to the engine’s own T3D export (523/523 brush models in DM-Rankin; 81k polygons across all 39 maps). Combined with schema-driven enums/arrays, overall T3D line coverage vs. the engine reference is ~92%.

Done

What parses cleanly today

Header, name/import/export tables, the full object inventory, the per-actor property set (class, Location, Rotation, DrawScale/DrawScale3D, names, refs, scalars, common structs), and a JSON actor model ready for transform/diff. Output reads exactly like a real DM map (StaticMesh="houretrim", Light with LightBrightness, PathNode nav lists).

Known limitations / not yet decoded


Toolchain — UT2004 + UnrealEd is installed ✅

A full retail install is present at C:\UT2004\System, which unblocks Steps 4–5:

Reference T3D: ucc batchexport DM-Rankin.ut2 Level T3D <dir> produced myLevel.T3D (~3.2 MB). It’s git-ignored (regenerable) but is the format spec for Step 4. Confirmed it matches our decoded properties exactly. Key format facts learned from it:


Roadmap

Step 5 — Round-trip in UnrealEd ← VERIFIED ✅

Imported import_kit/DM-Rankin-PlayerStarts.t3d into UnrealEd via clipboard paste (the .t3d text is UnrealEd’s actor paste format; there’s no ucc T3D-import subcommand).

Result — the read → decode → generate → import path works:

To land at exact absolute positions: set the drag-grid to 1 before pasting, or paste into the source map (overlay test) where actors drop onto the originals.

Import kit lives in import_kit/ (DM-Rankin-PlayerStarts.t3d, DM-Rankin-Lights.t3d, README.md).

Step 6 — Close gaps for a fuller round-trip ← mostly done

Step 7 — Whole-map brush round-trip (suggested next)

Generate full-map T3D (actors + brushes), import into a fresh UnrealEd level, Build Geometry → Build Lighting, and confirm the rebuilt level matches the original. This exercises the brush pipeline end-to-end (vs. the actor-only Step-5 verification).


Reference resources


Repo map

How to run

python ut2parser.py Maps/DM-Rankin.ut2              # summarize one map
python ut2parser.py Maps/*.ut2 --top 10            # summarize many
python ut2parser.py Maps/DM-Gael.ut2 --actors      # placed actors w/ location+rotation
python ut2parser.py Maps/DM-Gael.ut2 --json        # actor model as JSON
python ut2parser.py --diff Maps/DM-Rankin.ut2 Maps/DM-Gael.ut2   # per-class delta
python ut2parser.py Maps/DM-Rankin.ut2 --t3d > out.t3d           # generate full-map T3D
python ut2parser.py Maps/DM-Rankin.ut2 --t3d --t3d-class Light   # one class only

python navanalysis.py Maps/*.ut2                                 # bot-path/inventory report
python navanalysis.py Maps/*.ut2 --json                          # per-map data

# Regenerate the engine reference T3D (needs the install) for diffing:
#   cd C:\UT2004\System && ./UCC.exe batchexport DM-Rankin.ut2 Level T3D <outdir>

# Rebuild schema.json from the game's UnrealScript (needs the install):
#   cd C:\UT2004\System
#   for p in Core Engine UnrealGame XGame Gameplay XPickups XWeapons XEffects; do
#     ./UCC.exe batchexport $p.u Class UC <repo>\uc_export ; done
#   python schema_extractor.py uc_export -o schema.json