-
Notifications
You must be signed in to change notification settings - Fork 1
Engine Quirks and Stumbles
A running journal of things that surprised us — DS1 internals, .NET interop, file-format edge cases, and the occasional rake we stepped on twice. Each entry is structured the same way: What happened, Why, Fix. New entries go at the bottom of their section so you can read top-to-bottom in chronological discovery order.
If you fix a non-obvious bug, add the entry here before you close the PR. The next person to brush up against the same code should find it.
- Asset format quirks
- Engine constants that aren't where you'd think
- Actor / combat / progression
- World layers, fades & nav
- Character appearance: composited, not chosen
- Audio
- Save / load
- .NET / interop
- Process & workflow
DS1's binary and text asset formats are mostly straightforward — but where they're not, the surprises are sharp. Most of these came from "the parser ran clean and the bytes round-trip, but the result on screen is wrong." Texture orientation, animation layouts, attribute path lookups, and texture-stage descriptors all bit us before we learned the engine's conventions.
What happened. First HUD pass with bitmap-font glyphs rendered every line of text upside down. Looked like a Y-flip in the renderer, but every other texture (terrain, ASP meshes) was right-side up.
Why. DS1's .raw format stores rows bottom-to-top — common for the OpenGL-era pipelines GPG was used to. The mesh path happened to work because we sample with flipped V coords for those meshes; the bitmap-font path didn't.
Fix. Flip V on screen-down rendering paths (HUD, inventory icons, anything UI). The mesh path stays as-is. Memory: project_siegefx_raw_bottomup.
What happened. GAS files for spells contain [[ effect_script ]] blocks. Early parser would barf on the unexpected [[.
Why. DS1 splices runtime effect scripts inline using [[ ... ]]. The parser has to tolerate them as opaque text long before the runtime knows what to do with the contents.
Fix. Treat [[ ... ]] as a literal token that round-trips through the AST untouched. Phase 16 picks them apart at execution time.
What happened. First decode of b_gui_c_smash1.flm (the animated hammer cursor) showed 21 frames, but every third frame was a clean hammer pose and the others were stale-overlay-looking pixels — "cycling numbers in a rectangle" was how the player described it in-game.
Why. The file's actual layout is 7 frames of a 3-mip pyramid (32×32 + 16×16 + 8×8 BGRA), with each mip padded to a 4096-byte alignment slot = 12288 bytes per frame on disk. Old D3D loaders pre-allocated mip slots at fixed stride for direct GPU upload; SiegeFX renders the cursor at 1:1 so mips 1 + 2 are dead weight on disk but always allocated. Reading at stride 4096 (as if every 32×32 BGRA slot was its own frame) ended up showing all three mip slots of every authored frame, producing the cycle-through-junk effect.
Fix. Stride = 12288, read mip 0 only, skip the next 8192 bytes per frame. Math now: smash1.flm = 7 × 12288 + 2048 trailer = 88064 ✓, grab1.flm = 10 × 12288 + 2048 = 124928 ✓. Diagnostic CLI: siegefx flm dump <file.flm> <out-dir> writes every decoded frame as a PNG so the next time something looks off you can eyeball the contents instead of guessing the offset math. See reference_ds1_cursors_flm.md.
What happened. Wired cursor_cast_attack (spell mode hovering an enemy) to b_gui_c_magic3 per cursors.gas and the user reported "the red sword and glowing sword icon at the same time" — assumed double-draw bug.
Why. DS1's authored sprite for magic3 is literally both: a red sword silhouette with a blue magic glow drawn behind it. DS1 designers used compositional sprites to communicate "magic strike on enemy" (red = combat, blue = magic). It's one sprite that looks like two layered icons.
Fix. Ship b_gui_c_magic2 (the cast-on-terrain glow without the sword) for the spell-mode-on-enemy state instead. Loses fidelity to the original sprite but reads cleaner: red sword for melee, glow-only for magic, no overlap. magic3 remains the fallback if magic2 doesn't load.
What happened. Per-material barrel/crate/pot break sounds weren't playing on shatter. GetAttribute(t, "aspect", "voice", "die", "*") returned null on every shipped breakable.
Why. DS1 GAS supports inline colon shorthand for nested attributes — voice:die: * = X; inside an [aspect] block expands to [aspect][voice][die][*] = X. But the parser stores the attribute name literally, including whatever whitespace the author put between the colons. The barrel template ships:
voice:die: * = s_e_env_break_container_wood;
…which the parser stored as the attribute name voice:die:\t* (tab between the second colon and the asterisk). Equality lookup against voice:die:* (no whitespace) missed every shipped barrel.
Fix. Two-pass FindAttr: first try literal case-insensitive equality, then a whitespace-tolerant pass that strips internal whitespace from candidate attribute names before comparing. GetAttribute also walks the path progressively now — at every depth before descending it tries the colon-joined remainder as an attribute name, so the mixed inline-shorthand-inside-a-block case works without special-casing each call site. Audit receipt across all 81 shipped regions: 199 breakable templates resolve a break sound (158 wood / 36 clay / 4 stone / 1 die-scream) where pre-fix it was 0.
What happened. First pass at animating fh_r1's bridge water and wheelfall used a name heuristic — scroll any texture whose name contained fall. Wrong on both sides: it scrolled autumn-grass land tiles, and it skipped the wheelfall whose layer-1 texture is intentionally static.
Why. Every terrain texture has a TSD (Texture Stage Descriptor) .gas sidecar declaring 1–2 stages of fixed-function pipeline state — layer textures, frame-cycle counts, per-second u/v shift, colorop. Rivers ship as 4-frame cycles at 0.15s/frame; waterfalls ship as a static layer 1 plus a scrolling _dynamic layer 2 with colorop = modulate2x. The visible cascade is the second layer scrolling on top of the first.
Fix. Parse every art/bitmaps/terrain/**.gas into a TsdStore (3,318 records: 168 multi-layer foam recipes, 14 frame-cycle rivers) and drive the mesh shader from authored layer params. Diagnostic: siegefx tsd dump <Terrain.dsres>. Memory: project_siegefx_water_animation.
What happened. After plumbing layer 1 + 2 through the shader, multi-layer recipes still looked flat — no foam streaks anywhere. The motion that was visible came purely from the layer-2 sampler's UV scroll.
Why. Shader checked uColorop2 == 2 for modulate2x, but the C# enum (Modulate, Modulate2x, Arg1, Arg2) made Modulate2x = 1. Every multi-layer recipe fell through to plain modulate — layer 2 multiplied the base instead of doubling past it. Compounded by ignoring layer 1's authored colorop = modulate2x entirely; the foam recipes need both boosts to read bright.
Fix. Pass the enum value as-is and test literal enum values (== 1 for Modulate2x) on both uColorop1 and uColorop2. When uColorop1 == 1, double the layer-1 sample before composing layer 2.
What happened. Once modulate2x was firing, the wheelfall cascade scrolled upward.
Why. DS1 authored vshiftpersecond for D3D's V-down convention. Our terrain pass sets uFlipV = 1 to convert to GL's V-up. Adding +0.5/sec to a flipped V coordinate makes content scroll toward the geometry's top instead of the bottom. The mist's authored -0.20/sec was inverted the same way.
Fix. Negate the v component of both layer offsets in ApplyAnimatedTextureBinding (the terrain pass is the only consumer and always runs with uFlipV = 1). Cascade falls, mist drifts up. U axis untouched.
What happened. Mist quads at the wheelfall base rendered as hard-edged silhouettes — only the densest regions appeared, and the layer-2 modulate2x scrolling spray never showed.
Why. Foliage / fences rely on a discard if alpha < 0.5 cutout to mask leaf shapes. Mist's layer 1 is alpha-blended in DS1 with most of the quad below half-alpha; discard killed every fragment before layer 2 had a chance to contribute.
Fix. Lower the discard threshold to 0.02 only when uHasTexture2 != 0. Foliage keeps the original 0.5 cutout via a uniform branch.
What happened. With foam blending correct, dimmer flat-water tiles still covered the bright streaks across much of the wheelfall area. Two-pass region draw (single-layer first, multi-layer last) didn't fix it.
Why. Multiple SNOs sit at the wheelfall: t_fh00_wheeletc carries the foam plane; t_fh00_wfall_1b and t_fh00_rvr-custom-01 carry plain-water tiles authored at marginally higher Y. LESS depth rejects the foam fragment regardless of draw order.
Fix. Standard decal trick — enable glPolygonOffset(-2, -2) for the multi-layer pass only. Foam fragments win depth ties without visibly moving the geometry. Restored after the pass.
A surprising amount of DS1's gameplay numbers are derived at spawn / use time from formulas in formulas.gas, not authored on the templates. Templates carry placeholder fields and authored knobs that the engine ignores in favour of the curve. Hardcoding what looks authoritative in the template, or guessing at where rarity and break sounds live, leads to silent wrongness.
What happened. Spawned a gremal (DS1's smallest enemy) and it had ~85 HP — turning a one-hit kill into a slog.
Why. DS1 derives MaxLife per actor from a STR-based curve at spawn. The max_life field in the template is a static value used only for some special-case actors; for everything else it's effectively ignored.
Fix. Always recompute MaxLife (and MaxMana) from the formulas in formulas.gas at spawn time. The gremal's nominal 1 HP is the canary — if a fresh-spawn gremal isn't a one-tap, the formula path is wrong. Memory: project_siegefx_max_life_formula and project_siegefx_formulas_gas.
What happened. Almost wired the player as "fighter has no mana, mage has no STR auto-grow."
Why. DS1 has no classes. Every PC has all four skills (Melee, Ranged, Nature magic, Combat magic) and a single XP pool that's attributed to whichever skill earned it. The "class" you see in the manual is a starting kit, not a role lock.
Fix. PCs always carry a mana pool and full attribute trio. Skill kind is a runtime tag on each XP award, not a character class. Memory: project_dungeonsiege_mechanics.
What happened. Hardcoded the XP-per-level table for the prototype, then ran into "level 7 takes way too long" reports.
Why. DS1 ships its leveling table, attribute curves, regen rates, and damage modifiers in world/global/formulas.gas. Tweaks across the lifetime of the game live there.
Fix. Load from FormulasStore rather than constants. Memory: project_siegefx_formulas_gas.
What happened. First HUD layout pass placed the spellbar where it "felt right" — wrong location, wrong scale.
Why. DS1's HUD coords / textures are wired through /ui/interfaces/backend/art_mapping.gas. The original layout is in the game data; you don't have to re-invent it.
Fix. Read art_mapping.gas first when building any HUD/menu screen. Memory: feedback_siegefx_ui_authenticity and project_siegefx_ui_gas.
What happened. Phase 17-SC-K shipped breakable barrels and they appeared to work — clicks landed, the prop disappeared in a smoke puff. Three phases later, attempting the proper material-aware shatter sound + frag debris pass, the breakability flag turned out to be false on every shipped barrel. Phase 17-SC-K had been shattering nothing — every "barrel break" was actually TryClickToAttack's no-target fallback firing the same smoke puff at empty terrain.
Why. The original Phase 17-SC-K code looked up [aspect][break_particulate]. DS1 also has an [aspect] block on every breakable, so the path looked plausible. But every shipped breakable template authors the frag list under [physics][break_particulate]. The chain walk at aspect → break_particulate returned null, IsBreakable = false, and TryClickToBreakProp skipped every barrel.
Fix. Read [physics][break_particulate] instead. Audit CLI receipts after the fix: siegefx region breakable-audit all reports 1850 breakable placements / 1429 with pcontent / 9402 frag entries across all 81 shipped regions, where pre-fix it was 0. The GetSection chain walk on TemplateStore correctly resolves the inherited section from base templates (base_breakable_wood, base_container_barrel, etc.) so descendant templates that don't override the section still get the parent's frag list. Memory: project_siegefx_breakable_barrels.
What happened. Wiring spec rolls (#weapon/-rare(1)/200-314, #*/-unique(2)/175-286), the obvious next step was "look up equip_rarity on each template and filter." The attribute doesn't exist. There's no [gui][equip_rarity], no [common][rarity], no enum field anywhere on the ~2,800 weapon/armor templates that ship in Logic.dsres. Rolling under the rarity flag was a no-op until we figured out where DS1 was actually keeping the tier.
Why. DS1 splits the rarity signal across two different places. Uniques are mass-flagged for the normal-rolls path via [common] is_pcontent_allowed = false (every cb_un_* and bo_*_un_* template carries it; the runtime excludes them from generic specs). Beyond that, rarity is encoded in the template name segments — _ra_ = rare, _un_ = unique, else normal: cb_2h_axe_battle_ra_028 is rare, bo_bo_le_ra_010 is rare boots, cb_un_2h_troll_rock is unique. That's the same convention GPG's internal gaspy tooling parses in equipment.py:parse_template_name. Without name-segment classification, a -rare(N) modifier has nothing to filter on and falls back to the whole bucket, which is how we ended up with rare specs returning generic items.
Fix. Two-step: at index time, walk each template name's segments after the type prefix and tag the entry with Rarity = Normal | Rare | Unique; at resolve time, the rarity-modifier branch (-rare(N) / -unique(N)) restricts candidates to the matching tier, and the normal branch additionally drops anything with is_pcontent_allowed = false. Two related quirks fall out of the same investigation: spec power has no attribute either — for weapons it's round((attack.damage_min + attack.damage_max) / 2), for armor it's round([defend][defense]) — and armor templates don't author [aspect][model] because helms, boots, and body chests are texture overlays on the body mesh (only shields ship a free-standing mesh). The indexer can't require aspect.model for armor or you lose 435 of the 870 entries. Diagnostic: siegefx pcontent dump <tank> lists every bucket with rarity tags and runs sample rolls under any spec. Memory: project_siegefx_pcontent_resolver.
The actor pipeline glues animation, AI, navigation, combat, loot, and progression. The seams between those subsystems are where the bugs live: a brain that re-derives mode after teleport, save-restore that double-counts XP, swing clips that snap back to idle without the right pad. Most of these were "the test passed in isolation but fell over in flight."
What happened. Phase 19b first-cut tried to assign actor positions directly during save load. Compile error: NavFollower.Position is { get; private set; }.
Why. The follower is the one true source for actor placement at runtime, and direct external writes would desync the path-trace state. The private setter was deliberate.
Fix. Added NavFollower.Teleport(Vector3) that sets Position, sets Target to the same point, marks ReachedGoal, and clears the path. ActorBrain.Teleport wraps it and forces State back to Wander, so chase/attack re-aggro naturally on the next tick instead of being stuck in a stale state.
What happened. First save-restore round-trip doubled the player's STR/DEX/INT.
Why. AwardXp runs the level-up math and applies proportional gains. On load, the already-grown stats are restored to the actor first; running AwardXp after that would add the gains a second time.
Fix. Separate RestoreFromSave(totalXp, level) that just sets the fields, and a guard in the loader that restores stats before progression. Plus clear JustLeveledUp so a load doesn't re-fire the level-up chime for a level the player crossed mid-session.
What happened. Considered persisting the chase target / attack facing across save load.
Why. Those states are reactive — the next world tick will scan for hostiles in range and re-enter Chase or Attack on its own. Persisting them risks restoring an attack on an actor whose Scid no longer exists in the loaded save.
Fix. Save only the Wander state. Brain.Teleport clears _attackFacing and _swingCooldown and the world picks up where it left off in one tick.
What happened. Player melee swings always slashed in the same direction — never the L→R backhand the user remembered from DS1. The swing animation also felt "sped up", visibly cutting off before the arc completed.
Why. Two coupled bugs:
-
Sub-anim picker stopped at first match.
ActorSpawner.TryLoadChoreClipwalks[anim_files]and returns the first PRS that resolves. Forchore_attackthat's always0mid = at(the mid-swing R→L arc). DS1 ships up to 5 sub-anims (0mid/high/loww/extr/qffg— mid / overhead / low / lunge / quaff-fallback) and routes through aselect_attackSkrit that picks among them per swing. With the picker truncating at one, every swing was the same clip. -
Hardcoded duration of 0.6s.
PlayChoreOnce("chore_attack", 0.6f)truncated the override window before the clip's authored 0.83s forfs1(1H melee bucket). The clip got cut off at ~72% — never reached the follow-through pose.
Fix. New TryLoadAllChoreVariants collects every authored sub-anim PRS into an array (capped at 2 — the user's observed cadence is a 50/50 alternation between R→L mid-swing and L→R high-backhand, not the full 5-roster). Actor.AttackVariants stashes the array, Actor.SwingIndex rotates through it, Actor.PrepNextSwingClip() swaps the picked variant into Clips[chore_attack_idx] and returns its authored AnimLength. Both PerformPlayerSwing and PerformPropBreak call it at swing time. Memory: project_siegefx_breakable_barrels.
What happened. Even after the swing-clip duration fix, the user reported the swing felt "sped up". The arc completed, but reverted to idle the moment the final frame played.
Why. NPC brains pad chore_attack to SwingPeriod * 0.85 ≈ 1.28s for a 0.83s clip — clip plays in 0.83s, then the post-swing pose holds for ~0.45s before reverting. That follow-through window is what makes the swing read as weighty. The player path was passing the bare clip length, so the swing snapped straight back to idle the instant the arc finished.
Fix. PrepNextSwingClip returns AnimLength * 1.5 for the override window. Per-actor advance loop adds a non-dead end-hold branch (parallel to the existing dead-actor end-hold) that clamps AnimTime to AnimLength - 0.01 while a chore override is draining — without the clamp the clip would wrap and replay during the pad window, which reads as a glitchy "swinging-twice" visual.
What happened. Initial LootRoller picked one child uniformly per [oneof*] and recursed with each child's chance. fh_r1 barrels (gold@0.35 + potion@0.05) dropped at ~17.5% for gold and ~2.5% for potions — far below the community-observed ~35% / ~5%.
Why. DS1's [oneof*] semantic for chance-gated peers is "walk peers in order, first one whose chance passes wins, otherwise nothing." Under that reading, a fh_r1 barrel gets a 35% gold roll first; if it fails, an 0.65 × 0.05 = 3.25% potion roll; otherwise empty (~62%). Matches the observed distribution. The pre-fix uniform-pick semantic forced each peer through a 50% selection gate before its own chance fired, halving the effective drop rates.
Fix. EmitFromBucket walks children in authored order, fires the first whose chance passes, returns. Side effect: actor templates also drift toward more loot under the new semantic (krug grunt empty rate ~44% post-fix vs ~83% pre-fix), but in the direction the user's documented DS1 distribution prefers. Verified via siegefx templates loot krug_grunt --rolls=10000.
What happened. Per-region barrel templates (barrel_glb_fh_r1 etc.) authored [gold*] blocks that the LootTable parser didn't know about; krug + heroes templates put [gold*] directly under [pcontent] (no enclosing [oneof*] wrapper) which the outer-bucket loop also rejected. Result: every shipped gold drop was silently lost.
Why. The early LootTable.FromTemplate only matched IsOneof(header) for outer pcontent children, and the inner ParseBucket only walked oneof headers as children. [gold*] is structurally a leaf with chance / min / max attributes — needs its own parse path.
Fix. New LootEntry sentinel — Slot = "gold", Reference = "min-max" — emitted from [gold*] / [gold] headers. Outer loop in FromTemplate accepts both oneof and gold headers. LogPropLootDrop (and now LogLootDrop for actor death too) routes gold entries through _progression.CreditGold directly with a "+N gold" floating-text cue, leaving non-gold items to land in the resulting LootPile. Without the gold split, the synthetic "12-15"-shaped LootEntry would resolve into the inventory as a ghost item.
What happened. Hero walked too close to enemies and barrels — close enough to overlap their body bubble visually, making it hard to re-click the same target after a swing.
Why. HeroBaselineStats ships AttackRange = 0.5f (intentional wrist-length for the bare-fist case). PlayerMeleeReach used attacker.AttackRange > 0.1f ? attacker.AttackRange : MeleeReachFallback — the gate accepted 0.5 (> 0.1) as the player's real reach. With the standoff multiplier of 0.95, the hero stopped at 0.95 * 0.5 = 0.475u from any target's center — deep inside any ~1u-radius body. Two earlier "raise the standoff multiplier" attempts couldn't help because the underlying reach was already wrist-length.
Fix. Threshold 0.1f → 1.5f so anything below weapon-tip reach falls back to the proper melee distance (MeleeReachFallback = 2.3f). Net: hero plants ~2.18u from target center, ~1.18u clearance from a 1u-radius krug, ~1.68u from a 0.5u-radius barrel. Adjacent enough to swing but visibly not overlapping. Same path also added SnapPlayerFacingTo(target) at the top of PerformPlayerSwing and PerformPropBreak — pre-fold, finishing enemy 1 then attacking enemy 2 from the same melee position swung in the old direction (player facing only updated from movement deltas, and they hadn't moved between kills).
DS1's basement/cellar "cutaway" — the upper structure fading out so you can see into the floor below — took three separate fixes across render, nav, and AI to land, and it's still a little rough at the overlap seams. The render-vs-nav geometry split it leans on is documented in Architecture.
What happened. Early fade support hid the upper floor by dropping its triangles from the nav mesh too, so descending into a cellar produced hard pauses and a vanishing player body — the follower's standing/replan lookups treated the faded ground it was standing on as gone.
Why. A DS1 fade_nodes fade is purely a camera effect. The world underneath is still physically there — you still walk on it, the pathfinder still expands across it. The only job the fade does nav-side is layer selection: a click should resolve to the visible layer, not the hidden one above it.
Fix. Render fade and nav fade are separate gates. The pathfinder expands through fade-hidden triangles again and the follower treats hidden ground as physical; only click-picking (ray-pick + the click fallback) keeps skipping hidden tris so you target the floor you can see. Render fade is whole-snode granular, nav fade is per-lnode — see Architecture.
What happened. The obvious model — "fade the upper floor when the camera can see into the hole" (a frustum/reveal test) — never matched DS1. Sections stayed hidden or popped at the wrong times.
Why. Measuring special.gas with region layout --dump disproved the reveal theory: all 96 hc_r1 basement nodes sit within 26u of the entrance, and nothing anywhere reveals sections 3/4, so a frustum reveal can't be the mechanism. DS1's hide-basement triggers are BOX-FAMILY templates (trigger_fade_nodes_box) whose "out" fade holds only while a party member is inside the volume — the existence of the separate trigger_fade_node_offonly_box variant is the tell that the normal box is meant to restore on exit.
Fix. Box-family trigger instances restore their held "out" fade groups on the row's falling edge (when you leave the volume); "in" reveals stay sticky; offonly variants are excluded; trigger_generic choreography is untouched. An F7 diagnostic dumps the live fade state to a log file for chasing the remaining seam bugs.
What happened. A surface krug would aggro on the player standing in the cellar directly below it — "spotted me from across the map."
Why. Aggro distance was XZ-only, so a target one floor down but nearly overhead read as point-blank.
Fix. Aggro (and pack alerts) gained a vertical band of ~one story (4u): a target outside that Y band can't be spotted. Horizontal sight now uses the authored [mind] sight_range (krug 14u) rather than the old hardcoded 8u.
Still rough — parked near the finish. The reveal is nondeterministic per entry: in a single session you can leave the cellar and come back and get a different result, so the fade application is racing something on entry (trigger tick order vs. player-position / node state) rather than sitting in a stuck state. A separately-seen overlapping-fade-box re-occlusion had a per-applier ref-count fix that was reverted for trading one artifact for another.
F7dumps the live fade state to a log for the next attempt.
What happened. The ground rendered like a jigsaw assembled by someone who had every piece in the right spot but had turned each one: correct tile, correct texture, correct position — yet the pattern didn't meet across the seams.
Why. Where each tile sits was never in question. SNO tile geometry and corner UVs are read verbatim off disk, and the assembled world is navigable, so the data fixes placement completely. What the data can't tell you is that terrain was inheriting the shared mesh shader's unconditional V-flip — authored for the skinned-NPC pipeline — which mirrored every tile's texture for no reason. No field on disk is "wrong"; the renderer was simply facing each texture the wrong way, and nothing in the bytes says so.
Fix. Since the right orientation isn't recoverable from the data, we made it findable by eye: a U dev key that cycles all eight square-tile orientations (four rotations × two mirrors) live in-game. Cycling to the one where the pattern connected identified it in seconds — and it turned out to be the flip that cancels the inherited one, i.e. the raw authored UVs with no flip at all. A single global orientation fixed the entire ground, which also proved the problem was never per-tile.
The hero you build in the character creator isn't picked from a shelf of finished skins — DS1 composites it, layer by layer, at load time. Reconstructing that from the shipped bytes alone (the 2023 source leak is off-limits) meant decoding the actual textures and looking at them, then cross-checking against the retail game.
What happened. The creator exposes six rows — gender, head, face, hair, shirt, pants — yet siegefx asp info on m_c_gah_fb_pos_a1.asp reports 2 textures, 5 subsets: slot 0 (skin) is drawn by four subsets, slot 1 (clothing) by one. Two slots can't independently hold a head, a face, hair, a shirt, and pants.
Why. DS1 builds each slot from overlay families. Decoding the shipped .raws and viewing them made it plain: b_c_gah_fb_skin_NN is a face+arms atlas; b_c_gah_fb_hair_NNN is a hair-only overlay (hair painted in the head region, blank everywhere else); b_c_pos_aN_shrt_NNN paints the torso, b_c_pos_aN_pant_NNN the legs. So the skin slot = base skin + hair overlay, and the clothing slot = shirt + pants overlays. The tell was in the texture .gas descriptors: they're TSDs with a layer1texture1 field — the multi-layer hook the engine composites through.
Fix. Composite the two slots on the CPU by alpha-blending the overlays over a base and uploading the result (see the two entries below).
What happened. In the creator the arrows plainly changed the wrong features — "Head" reshaped the body, and hair/shirt/pants barely produced distinct results.
Why. Because the mesh only exposes two slots, an earlier pass folded everything into them with index arithmetic: Head cycled the body mesh, Hair was folded into the skin atlas, and Shirt and Pants both folded into a single clothing atlas. The separate hair / shirt / pants overlay families DS1 actually ships were never used, so no lever matched its label. Playing the retail game beside the preview pinned the true mapping: Head = hairstyle (which overlay = the shape), Hair = colour of that style, Face = skin / skin-tone, and Shirt / Pants their own overlays — with hairstyles gender-locked, which falls out of the fb / fg family stem for free.
Fix. Rewire each lever to its real family and enumerate the variants that actually ship. Skin numbering is sparse and salted with blank placeholder slots — and some of the "extra" skins are real NPC faces, not creator options — so blanks are detected (fully transparent or a single flat colour) and skipped rather than cycled.
What happened. An earlier compositing attempt "broke face details" and was reverted to the folding hack. The obvious approach — blit each overlay into a hardcoded rectangle of the atlas (a head box, a torso box, a legs box) — is exactly the trap.
Why. Those rectangles never line up perfectly; a few pixels of drift and the face smears. But the overlays don't need regions at all: the .raw format is BGRA 8888 with a real alpha channel, and each overlay is painted only in its own area with everything else transparent. The overlay's own alpha is the mask.
Fix. Alpha-blend each overlay over the base pixel-for-pixel — no rectangles, no drift. Hair colour is the single thing that can't be recovered clean-room (the exact style×colour grid lives in the unreadable source), so it's a recolour tint: neutralise the hair overlay to luminance, then multiply by a palette colour, so one style can be any colour. The composite feeds both the live 3-D preview and the spawned player.
OpenAL Soft, NLayer mp3 decode, and the SED registry are mostly well-behaved on their own. The traps live at the boundaries: coordinate handedness vs. the renderer's, music data half-parsed but never consumed, sample-vs-byte counts in NLayer's API, mp3 paths case-sensitive on the tank side but mixed-case in mood gas. Several of these silently emitted noise or silence for months.
What happened. First Phase 18 audio mix had Z negated on listener and source positions. Sounds appeared to pan correctly until you noticed that walking toward an enemy made the swing sound quieter on the dominant side.
Why. OpenAL uses a right-handed coordinate system that already matches DS1's world axes. Z-flipping was carryover from an earlier renderer experiment that did need a flip; it was never re-evaluated when audio came online.
Fix. Removed the negations from both PlayAt and UpdateListener. Comment in AudioEngine.cs explains why so it doesn't get "fixed" back.
What happened. Random AL_INVALID_OPERATION after extended play sessions; single-source SFX that should always play would occasionally drop.
Why. The voice recycler was deleting the OpenAL buffer before the source had finished detaching from it. Source detach is async-ish — you have to wait for the source state to leave Playing before the buffer is safe to delete.
Fix. Two-stage retire: source goes back to the free pool first, buffer delete happens on the next sweep when the source is verified Stopped. Logged in the Phase 18 review commit.
What happened. A spell with a typo'd variant ID would just play nothing — no error, no warning, no fallback.
Why. Audio is intentionally graceful-fail (a missing SFX shouldn't crash the game). But the silence was indistinguishable from a missing file.
What happened. First grep for music files looked for a dedicated Music.dsres tank, then fell back to checking *.mp3 outside the tanks (in case the GOG re-release stripped CD-DA audio). Neither produced anything; appeared like the build shipped without music.
Why. DS1's tank layout puts both SFX and music inside Sound.dsres. SFX live at /sound/effects/*.wav (~600 wavs); music at /sound/music/*.mp3 (131 tracks across 44 theme categories). The naming convention encodes both: s_e_* is sound effects, s_m_* is music. system/mss/Mp3dec.asi next to the EXE is the Miles Sound System mp3 decoder DS1 calls into.
Fix. Stream from the tank: siegefx music play <Sound.dsres> <basename> extracts the bytes via TankReader and hands them to a streaming MusicPlayer. SFX path was already wired through Sound.dsres for /sound/effects/*.wav; same handle now serves both. Memory: reference_ds1_audio_tank_layout.
What happened. Mood definitions ship standard_track = "s_m_Farmhouse_02" and s_m_Path2Crypts_01 with mid-string capitalization. The actual mp3 paths in the tank are all-lowercase (/sound/music/s_m_farmhouse_02.mp3). First wiring of ApplyMoodMusic used string.Equals(StringComparison.Ordinal) somewhere along the path and ate every region's music silently.
Why. GPG's content tools normalized filenames at extraction but mood-author DS1 had different conventions across s_m_main_theme (lowercase) and s_m_Farmhouse_02 (camel). Tank handles are case-insensitive; the GAS values are author-original.
Fix. Use OrdinalIgnoreCase everywhere along the chain — MoodStore parse (already does), ApplyMoodMusic's s_m_ prefix strip, PlayMusicTrack's _currentMusicTrack equality check, TankReader lookup. End-to-end case-insensitive resolution.
What happened. First MusicPlayer.FillBuffer looped while (sampleCount < target) and asked NLayer for ChunkBytes / 2 samples per call. NLayer happily decoded that many bytes (not samples) and the loop overflowed the chunk buffer in 1-2 iterations, producing garbage frames in OpenAL.
Why. Some MPEG decoder libraries (NAudio.Lame is the canonical one) use sample-count semantics for their byte-overload arguments. NLayer's MpegFile.ReadSamples(byte[], int offset, int count) follows the .NET stream convention — count is byte count, return is byte count. The float-overload ReadSamples(float[], …) IS sample-count. Easy to mix up.
Fix. Pass byte counts, accumulate byte counts. int got = _decoder.ReadSamples(scratch, written, ChunkBytes - written); written += got;. Sample math at the OpenAL upload boundary instead of at decode time.
What happened. Phase 21d-2a-xi's MoodStore shipped with StandardTrack and BattleTrack already in the parse tree, with an XML doc note saying "Captured for the future music-streaming slice; xi does not consume it." Six months later SC-MUSIC-C started reading StandardTrack and the comment became stale.
Why. The xi author predicted the music slice would need these fields and pre-paid the parse cost; it's the right cost-amortization call (parsing later would have meant a separate sweep over 520 mood definitions). But "captured for future" comments rot if no follow-through ships.
Fix. Update the xmldoc to "Consumed by Phase 22-SC-MUSIC-C's RenderHost.ApplyMoodMusic." The pattern itself is fine — just refresh the doc when the consumer lands. Memory: feedback_phase_review_standing (review-the-diff-as-someone-else's-PR catches stale comments).
What happened. Wired prop-break audio via PlayDeathSfx's [aspect][voice][die][*] lookup; barrels were silent on shatter. Adding the [physics][break_sound] fallback fixed barrels but the same lookup also needed to skip empty values.
Why. DS1's content teams used different conventions per material:
-
Wood barrels/crates/breakable doors:
[aspect][voice][die][*] = s_e_env_break_container_wood; -
Stone / clay / metal containers:
[physics][break_sound] = s_e_env_break_container_stone;
Same shipped sound library, different attribute paths. The voice:die form is the reusable "I died, here's my voice cue" hook (also used for screams on actor death); the break_sound form is dedicated to the static-prop shatter pipeline. Plus DS1's powder-keg variants author break_sound = ; (intentionally empty) to suppress the default cue and route through a camera-FX explosion instead.
Fix. PlayPropBreakSfx walks both paths: prefer the voice/die hook (reusable cache with PlayDeathSfx's registered cues), fall back to physics/break_sound, treat empty/whitespace as "no cue" so the powder-keg case stays silent. Audit shows clean per-material distribution: 158 templates wood / 36 clay / 4 stone / 1 die-scream (chapel window).
Fix. Log variant typos at load time, not at play time. The game still runs without audio; you just see the missing IDs in the console at startup.
The save format is JSON for human-debuggability, schema-versioned, and atomically written. The discipline that grew out of these entries: save format is a contract that fails loudly on mismatch, never silently re-interprets stale data, and clamps everything on restore so a corrupt file can't drive the runtime into impossible states.
What happened. First Phase 19a serialization wrote actor positions as {} — empty objects. No error.
Why. Vector3's X/Y/Z are fields, not properties. System.Text.Json serializes properties by default. Configuring it to include fields was an option, but doing so globally is invasive.
Fix. Local Vec3 record struct with X/Y/Z properties, used only at the save boundary. Conversion is one line on each side. Worth the duplication to avoid leaking serializer config across the codebase.
What happened. SaveStore.Save worked perfectly in the round-trip self-test until we ran the test twice. Second call corrupted the file.
Why. File.Replace (the atomic temp+swap path) only takes the swap branch when the destination already exists. The first write hit the simple File.Move branch. The bug was in the swap path, exclusively.
Fix. Self-test always writes twice. The second write is the one that exercises the real production code path. Pattern is general — atomic-write tests must include an overwrite, not just a fresh-file case.
What happened. Considered "load best-effort" for unknown schema versions to keep old saves working through development.
Why. Best-effort load of an unknown schema is worse than a hard refusal. It produces saves that look fine until a field rolls over to a default and the player loses an item, an XP delta, or a quest flag with no trace.
Fix. SaveStore.Load throws InvalidDataException on schema mismatch. The self-test bumps the version on disk and asserts the throw. From v0.14.0 onward, the schema is a contract: bumping it requires a migration in SaveStore.Load, not silent acceptance.
What happened. Considered trusting the saved cooldown values directly.
Why. A corrupt or hand-edited save with a negative cooldown would never tick back to zero (the tick guard is if (cd > 0) and a negative value never enters that branch — the spell is permanently locked).
Fix. RestoreCooldowns clamps to MathF.Max(0f, value). Same defensive clamp pattern in RestoreFromSave for HP/MP. Costs nothing, prevents a class of bricked saves.
What happened. Considered loading any save into the current region.
Why. Save records actor Scid + position. If you load a save from fh_r1 while standing in castle_ehb_r3, the loader would happily splice fh_r1's goblins into Ehb's halls.
Fix. ApplySave refuses (with a console message) if save.RegionPath != _regionPath. The frontend can offer a region-switch prompt later — the engine layer just refuses the unsafe operation.
What happened. First save schema had Inventory and Equipment as separate fields; load logic was forking on which to use for each slot.
Why. The two are conceptually one list — items the player owns, some of which happen to be equipped. Splitting them into two snapshots invites them to drift out of sync (item appears equipped and in the bag).
Fix. Single Inventory list of slots; equipped items get an equipped:<slot> prefix on the slot name. Loader splits on the prefix at one site. Memory of the structure stays in the schema; runtime decomposition is trivial.
Two entries here for a reason: most of the .NET layer is unsurprising. The traps that did land are about not letting tooling shortcuts (skipping git hooks, ignoring resize storm) hide real problems.
What happened. Dragging the window edge to resize fired OnResize on every WM_SIZE — cumulatively dozens of times per drag. Each fire was attempting to reconfigure the renderer.
Why. Direct OS messages for resize don't coalesce; you have to do it yourself.
Fix. Debounce resizes with a 150ms timer; the actual reconfigure runs once after the user stops dragging. (Same pattern Emutastic uses for Vulkan swapchain resize — see project_vulkan_resize_debounce for the sister memory.)
What happened. Tempting to skip pre-commit hooks when "I know this is fine."
Why. The hooks have caught real regressions multiple times — formatting-only? Sometimes. But "formatting-only" includes "dropped a using because formatter saw it as unused" which has shipped a build break before.
Fix. Always fix the hook failure. Memory: feedback_phase_review_standing (the broader phase-boundary protocol).
These aren't engine quirks at all — they're operating disciplines that emerged from things that hurt: bundled commits whose review found bugs that needed follow-ups, phases that landed without smoke-test entries and went quietly stale, chrome guesswork that ate sessions before someone ran trace-pose. Listed here so the next person doesn't relearn them the slow way.
What happened. Tried to bundle Phase 17a + 17b in one push. Review found a bug in 17a that required a follow-up commit and made the diff hard to review.
Why. Small slices are review-able. Two sub-slices at once doubles the surface area for reviewer load and triples it for blame archaeology when something goes wrong six months later.
Fix. Sub-slice cadence is non-negotiable: commit, push, tag if appropriate, then start the next slice. Memory: feedback_phase_review_standing.
What happened. Phase 16 added spell casting; first attempt to verify was "launch the game and try it." Discovered three minor bugs that a dedicated CLI test could have caught in seconds.
Why. test-all.bat is the project's smoke-test menu. If a phase isn't in there, the next session has to remember to test it manually. They won't.
Fix. Every phase ends with a new T## entry in test-all.bat — either an automated --selftest-* flag or a human-walkthrough script for visual phases. Memory: feedback_siegefx_test_bat.
What happened. Multiple HUD chrome sessions (Difficulty button rects, character-creator arrow positions, the SP-submenu BACK backing) burned hours guessing at PRS clip times, subset bind-pose extents, and texture UV crops. Each guess that "almost worked" turned into a session-long black hole.
Why. DS1 authors HUD widgets per-button as ASP meshes with subset masks driven by PRS clip animations on a state-prs file. Bind-pose extents lie about where the widget will land — "looks right" at one PRS time is two pixels off at the next, and you can't see that without applying the clip. Rendering is per-button asp draw, never one mesh with all subsets baked in.
Fix. Standing rule: run siegefx asp trace-pose <chrome>.asp <state>.prs 1.0 before editing any rect, PRS, or UV math. Then read art_mapping.gas to find the recipe (subset → texture state-swap), then read the screen's own .gas (e.g. main_menu.gas, character_select.gas) for placement rects. Memory: feedback_siegefx_frontend_screen_recipe. The CLI itself is documented on Diagnostic CLI Tools.
What happened. Pushed Phase 17 before reviewing the diff. Found two issues post-push, had to push fix-up commits.
Why. Reviewing the diff is more honest before you push — pushing first creates pressure to "let it stand" rather than rework.
Fix. Commit locally, review the diff as if it were someone else's PR, fold any findings into the same commit (amend is fine here, it's pre-push), then push and tag. Memory: feedback_phase_review_standing.