Here's the update video, thanks to
@slygamer's hardware capture:
EDIT:
Disclaimer: As always, feel free to correct me, as it might me help understand better and maybe fix some bugs. I'm still exploring the hardware, I might miss something or say something plain wrong. The following no source of truth value, only my current understanding of things applied to the port.
Tethys Odysseus - Oddworld: Abe's Oddysee on Sega Saturn - update (4–10 August)
Note that the video covers a slightly wider window than this post. It goes back to
late July, so it also shows the audio and sprite-ceiling work from the last update.
Since August 3rd the port went from "the level runs" to "the level looks like the game".
Below is what changed, plus the two things I got wrong.
── DISPLAY: NATIVE 240 LINES ─────────────────────────────
The renderer had been running 320x224, with the vertical pre-scale applied
NEAREST-NEIGHBOUR at 14/15 — one scanline in fifteen was deleted, and which one
shifted as a sprite moved vertically. That is a shimmer in motion, not a uniform
softening. It is now 320x240: 1:1 vertically with the PSX original, no resampling at
all, horizontal still a clean integer 2:1 from the 640-wide PC art.
Eleven sites had to move atomically for that (the pack contract is mirrored in Python
and C++), and a twelfth escaped the platform layer entirely: the streaming CAM path
validates the Bits header independently, inside RELIVE, and still demanded 224 rows.
First build booted straight into a fatal. A dimension contract that wide needs a
literal sweep across BOTH repos.
── WHAT PAID FOR IT: THE VDP1 TAIL ───────────────────────
Back at S5, 217,088 bytes of CPU-only tables (the AO VAG table 147,456, the sound-entry table 36,864, the renderer's CLUT mirror 32,768) had been exiled into the tail of VDP1 VRAM, because LWRAM was fully committed to the resource heap. That was always a debt, and it was charged to the texture heap: 229,376 usable bytes out of a possible 450,560.
A LIFO tail-trim: SRL's texture allocator is apure bump allocator with no per-id free, so retired slots ARE the free list, and
42-61% of every byte ever claimed was sitting in them. Everything above the highest id held by a LIVE slot is retired by construction and comes back in one call. No search, no adjacency test. Measured peak went 281 -> ~185 KiB.
Consequence: 240 lines fit under the 256 KiB no-cart ceiling with 58 KiB spare. That killed the dual-pack / cart-tier / disc-fork plan outright. One pack, one image.
── CONVERTER: TWO FIDELITY WINS, ZERO RUNTIME COST ───────
1. Anim chunk compaction. The converter halves every cel for Saturn but was keeping each frame at its PSX offset and zero-filling the slack. Measured over the converted R1.LVL: 38.7% of ALL Anim payload bytes: 952,656 of them, ABEBSIC.BAN alone 93,512, was padding, and the ResourceManager pays for it twice (resident block + contiguous stage). Repacking end to end and rewriting every cross-reference took the R1 pack from 7.66 to 6.59 MB. Lossless: all 4202 frames of 219 chunks re-decode bit-identical.
2. Stopped decimating actor cels. The 2:1 horizontal pre-scale was nearest-neighbour ON PALETTE INDICES. Indices are indeed meaningless to filter. Colours are not.
Dropping every other column discarded a DIFFERENT colour on 37.77% of opaque texels (1,479,863 measured). It now averages the covered columns in COLOUR space and snaps the result back into the SAME palette. 0 bytes of VRAM, 0 fill, 0 runtime code, 0
display-mode risk. This is plausibly most of what read as "the Saturn version looks halved", and it was available all along behind a comment that was true about indices and wrong about pixels.
── THE LED MARQUEE FINALLY RENDERS ───────────────────────
Prior note: LCD text is still bugged and have poor performances.
LCDFONT.FNT was the one resource the converter skipped, so the LCD factories had been stubbed since S4 after a null AliveFont sent DrawString polys into BIOS ROM.
French and Spanish marquee text are extracted offline from the localized executable by signature search (the two LCD palettes that follow the table), not by hardcoded offset. The tutorial strings also name real Saturn buttons now: Input_GetButtonString had been a stub returning "" since P1, so 12 of the 37 messages read "To jump, press ."
One good bug from that work: the French disc went black at boot. Root cause was a single signed char. String_FormatString reads the marquee as plain `char`, this port forces -fsigned-char, so every CP437 accent arrives NEGATIVE, fails `>= ' '`, falls into
the button-glyph branch, and `in_char - 6` truncated back to char turns 0x82 into +124. That indexes a 53-entry table at 124, lands in unrelated .rodata, and strcpy runs unbounded into a 512-byte buffer inside the ~30 KB HWRAM pool. No fatal, no exception screen, a heap smash is not a CPU exception.
── COLOUR: THE TINT CHANNEL ──────────────────────────────
Two reported defects, one cause: characters were not being hidden by dark columns, and Mudokons were the same colour as Abe. AO darkens AND tints an actor by modulating its PRIMITIVE RGB, and our textured path read that RGB nowhere.
Abe and the Mudokons genuinely SHARE one CLUT in the game data, so the tint is the ONLY thing that tells them apart, dropping it made them identical by construction.
VDP1 gouraud cannot stand in here: in a colour-BANK mode it offsets the colour CODE, i.e. the palette INDEX, which on an arbitrary AO clut samples unrelated colours (this is the red errata on VDP1 manual p.93 in the Kronos-corrected scans). So the palette itself is modulated, which is effectively what the PSX hardware did. Sized from the level data rather than from a gauge: R1 has exactly ten ShadowZone TLVs over 8 cameras of 105, never more than two on one camera, which is what makes a four-entry cache enough, and why this costs NO new CRAM.
Same pass found that non-character 8bpp takes the ctor default 105 = 81% brightness, so the whole prop layer had been a fifth too bright since S5.
── LOADING: ~8 s -> ~2 s ON HARDWARE ─────────────────────
The CD reader was doing one GFS_Seek plus one blocking GFS_Fread PER SECTOR. 40 to 55 drive round-trips per screen change, purely because the bounce buffer was 2 KB of HWRAM. It moved to LWRAM. 16 sectors a round-trip now.
The second half is perceptual and is not a performance fix: the screen dims to black over 4 presents WITH THE SPRITE LIST STILL LIVE, holds through the load, and ramps back over 8 composed frames, so the game is already running and the actors come up with the background instead of after it. Before, you watched the new screen paint itself in from the top as sectors landed. The video has a side-by-side.
── AND ONE 19-MILLISECOND CALL ───────────────────────────
On the tick Abe chants, ONE Vram_alloc_block call cost 19.0 ms. 505,344 SH-2 cycles, 95% of the worst VUpdate and 73% of the whole update phase on a 67 ms tick. Not an accumulation: one query.
The stock allocator picks direction by AREA. h*w >= 1024 descends; everything smaller ASCENDS from y = 0 one row at a time. A 20x21 chant orb therefore walks the contested end of VRAM and calls Vram_Is_Area_Free 2*(256-h) = 470 times. The fix skips the rows that provably cannot be the answer (a row only improves on the one below by LOSING a blocker, and a blocker leaves at its bottom edge), keeping the stock predicate as sole authority on x and collision. Worst allocator tick 3948 -> 760 raw ticks, update phase 26 -> 18 ms, worst tick 67 -> 51 ms.
── TWO THINGS I GOT WRONG ────────────────────────────────
1. My previous allocator "optimisation" was a 72-84% REGRESSION on hardware. Five controlled comparisons at identical occupancy, no exceptions. The equivalence proof was correct and irrelevant: it showed the ANSWER is identical and the ITERATION COUNT drops, and never priced one iteration. The stock loop early-exits at the FIRST blocker; mine scanned all N every iteration, and N reaches 55 in the field. A complexity bound is not a measurement. Reverted.
2. I spent four builds hunting the in-play slowdown by naming suspects. Six mechanisms measured and refuted. Accumulation, sub-rect fill, the palette hash, VDP1 fill, the submission path, the SCSP key-on busy-wait. Every one of which I had priced by READING the code and then quoted as if measured. So I stopped naming suspects and split the clock instead. Result: pu 13.6 / pa 11.5 / pv 8.4 / pw 8.5 ms per tick against a 33 ms budget. Three unrelated phases, different authors, different data, all slow in the SAME proportion. That is not an algorithm, it is the signature of a GLOBAL factor, and ours was written in our own Makefile: the core is compiled -Os because code size was the binding constraint at P1, and I forgot to reopen it after the RAM cart moved the resource heap. That is the next piece of work.
── STILL BROKEN, NAMED ───────────────────────────────────
- In-game frame rate. On hardware, 57-92% of ticks run late on heavy screens. The LCD boards are the first suspect: every screen carrying one runs measurably slower.
- Door lamps are still the raw first-pass conversion. The 4bpp glow path can't go through a CRAM colour bank, because a bank only ever treats texel index 0 as transparent, while the lookup-table path can make ANY entry transparent, a cel elying on several transparent indices paints its bounding box.
- The elevator chain still renders wrong (parts missing, no animation)
Faint white lines appear during the chant.
── WITH THANKS ───────────────────────────────────────────
@slygamer: hardware captures and testing; everything in the last video is hers.
@Wesker: hardware testing. Several fatal-error reports from real hardware led straight to root causes I could not have found in emulation.
@carlos24_: emulation testing, the shared-palette idea behind the character colours, and this post that started the whole port.