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 (18 August - 14 September)
Four weeks since the last update, so two videos:
Devlog #7: real Saturn, RAM cartridge fitted
Devlog #8: build 412.ao.1, 14 September, real Saturn, no cartridge (the target configuration)
The headline: the movies play on a real Saturn, streamed from the disc, without the cartridge. Below, in order: the disc, saving, the frame budget the last update left open, the movies, the barrels, the rest of the game, two fixes made after the devlog #8 capture, and two things that went wrong.
── THE WHOLE GAME ON THE DISC ────────────────────────────
All fourteen levels convert, now that the path layout comes from the PathData.cpp tables instead of being written by hand, and all fourteen go on the CD: 249 MB of PC data becomes 77 MB of Saturn packs, about 12% of a disc before the movies. Until then only R1 was packed, so a level transition had never once executed. Two checks came first: LvlArchive::OpenArchive overwrites the PSX disc position with PSX_CD_OpenFile(fileName) on the first line, so level loading is name-addressed end to end; and the highest path index in the whole game is 20, so the kMaxPaths 99 -> 24 cut from the last update holds for every level. The 32 MB ZZPAD.BIN filler (added when an ODE did not recognise a tiny image as a CD) is gone.
── SAVING ────────────────────────────────────────────────
Four slots in backup RAM behind SaveGame::SaveToFile/LoadFromFile, with a 32-byte header carrying the title so the load menu can round-trip the name (no keyboard, so the typed name of the PC pause menu never happens). The device is swept at init, not assumed: BUP_Init fills three entries and the highest present one wins, as SlaveDriver does. Since 12 September the pause menu lets the player pick the slot: the four slots with the stored titles or "-- LIBRE --" (I don't remember if I localized it), the device named above the list, and the cursor on the slot the backend would have picked alone (first free, else the oldest).
── WHERE THE LZSS DECOMPRESSOR WRITES ────────────────────
A three-arm A/B on hardware, 154 balanced samples. The console's own LWRAM decompresses about 12% faster than the RAM cartridge, and the cartridge reached through the uncached mirror is about 80% slower than through the cache: the same RAM, one address bit apart. The SH-2 cache is write-through, so the stores leave on the bus either way; the 80% is carried by the reads, the LZSS back-references re-reading what was just written. Ymir had read 1.00 and 1.18. Worth about 0.85 ms a frame; the buffer moved to LWRAM.
── THE FRAME BUDGET, MEASURED ────────────────────────────
The last update left "which phase dominates a typical frame" for this one. The first answer is that no single frame exists: four screens on hardware (ao242.11) cost 33.7, 35.3, 48.2 and 53.0 ms.
Two constants came out instead of a ranking. ScreenManager::VRender's fixed 300-iteration loop cost 3.3-3.8 ms on every tick of every screen, 84% of the render phase on a screen drawing two textured rects, and the CAM path discarded 111-170 primitives a tick. One mechanism seen from both ends: a PSX dirty-rectangle repair loop building primitives for a frame buffer the Saturn does not have. Removed in ao242.12, since the loop cannot change a pixel.
Then the render seam. On hardware the renderer tracks CALL COUNT, not sprites and not fill: flat at 11.3-12.8 ms while sprites go 39 -> 64 and texel area 15,616 -> 20,480. Two code regions with nothing in common in work terms cost the same per call, which a fetch model explains and a work model cannot: SubmitTexturedRect (7,748 B), TextRunFlush (2,616) and PSX_DrawOTag (4,404) are about 14.7 KB of code fetched through a 4 KB cache, 53 times a frame. Three "do less" levers bought nothing; the one "touch the bus less" lever paid (the palette copy in longwords, CRAM being cache-through). Splitting the biggest function to shrink the fetch was tried in 300.ao.1 and refuted in 301.ao.1.
Ymir under-reports, and not by one factor. Same screen, same counts: the whole work 1.75x slower on hardware, the render phase 1.47x, an early-exit call 2.41x (133 -> 320 µs). A lever ranking made on Ymir alone comes out in the wrong order.
The glyphs. VDP1 has no texture stride, so a letter cannot be cut out of a font sheet: every letter was copied into a dedicated block, every frame, at 33 raw ticks a letter on Ymir and 40-43 on the console. The glyph cache (289.ao.1) composites a glyph once, keyed on the slot's texture id and upload serial, so any re-upload misses instead of hitting a stale entry; the text pass fell 4 to 5 times. And the compositor had never composited: the run broke between letters because the scale was recovered per glyph from a destination width in Saturn pixels (x >> 1) against an atlas width in texels, which jitters with glyph width and destination parity. The scale moved onto the glyph (315.ao.1): strips flushed against glyphs composited went from 64/90 to 17/92 on the GameSpeak screen. No fps claim for either.
── NINE FACTORIES ────────────────────────────────────────
With fourteen levels on the disc, a CheckResourceIsLoaded hardened into a fatal error killed a game every few screens. All 116 factories in AliveLibAO/Factory.cpp were audited, pairing what each construction branch requires against what the load branch fetches: nine disagree, and five have no load branch at all. Not nine bugs but an engine convention: those resources arrive through the path's list or a neighbouring camera's load, and the original answers a miss with a log line. RELIVE says as much in Dove.cpp, which carries a rescue named "hack loading dove resources". Hardening that check was the mistake; a miss is now a counter, with the last missing id on the overlay.
── THE MOVIES: FROM .DDV TO SEGA FILM ────────────────────
The PC game's .DDV files carry the PlayStation bitstream: MDEC video and XA-ADPCM sound at 37,800 Hz stereo. A host-side extractor demuxes the files with RELIVE's own PSX MDEC and ADPCM decoders, mirroring the loop in AO/Movie.cpp: 15 fps frames and a 37,800 Hz WAV. 17 titles, 655 seconds, 9,818 frames. Eleven are 320x192; the logos and the monster intros are 320x240.
Encoder: Radius Cinepak, the period codec, which still ships with Windows as SysWOW64\iccvid.dll. A small 32-bit harness drives the codec in-process through Video for Windows (ICCompress). ffmpeg's own cinepak encoder was dropped: -b:v is inert (three bitrates, byte-identical output), and at matched bytes on GAMEBGN, the worst title, the worst ffmpeg frame held 481 stale 4x4 blocks against 182 for Radius.
One LIBCPK limit worth knowing before adding strips: strip 0 uses codebook slot 0 and every later strip uses slot 1, hard-coded, with no bound check (read in the disassembly). Two strips play; a third aliases the second.
Container: ffmpeg's film_cpk muxer writes the Sega FILM file, then three custom passes rewrite the file: every keyframe gets full codebooks, the payload is reordered so the sound leads, and every audio chunk moves onto a 4-byte boundary. The next two sections explain the last two.
── WHAT A COMMERCIAL GAME'S FILMS TAUGHT ─────────────────
The reference: the nine FILM files of a commercial Saturn game built on the same LIBCPK, read byte by byte.
Keyframes. All nine films have a maximum keyframe gap of exactly 4 frames (0.3 s), with one strip or two. Radius's default had allowed 505 frames (33.7 s), and that disc showed parasitic squares on the console that no desktop decoder reproduced: ffmpeg's reference decoder replays the same file with zero bad blocks. The films now carry a keyframe every 4 frames.
Sound ahead of the picture. Audio lead = seconds of sound delivered minus seconds of picture shown, evaluated sample by sample in payload order, which is LIBCPK's consumption order (audio samples carry no timestamp). In the commercial films the lead never drops below zero; on the two measured in detail the mean is +0.48 and +0.50 s. ffmpeg's film_cpk muxer interleaves by timestamp with no lead, so every pack of this port dipped below zero (-0.29 s at worst on BEGIN). At that point the library can only wait or fill silence (silence_rate, sgl_cpk.h:206), and the sound stuttered. cpk_audio_lead.py reorders the finished payload: same samples, same bytes, same video timestamps, a 16,536-byte preload, minimum lead +0.000 s, mean +0.59 to +0.66 s. The pass verifies by decoding both files and requiring identical frame hashes and an identical audio stream.
── THE CLICK: 4-BYTE ALIGNMENT ───────────────────────────
Irregular clusters of clicks, on Ymir and on hardware, surviving 16-bit, 22,050 and 32,000 Hz, packet size and cadence, and volume. A click that survives every encoding parameter is a layout defect. About half of the audio packets started at an offset of 2 mod 4, because the video chunks in front have arbitrary sizes, and each misaligned packet clicked. In the commercial films, 100% of the audio packets sit on a 4-byte boundary.
cpk_align4.py pads each video chunk to a multiple of 4 (the Cinepak decoder goes by the length stored in each frame, so 0-3 bytes of padding are ignored). A re-mux, not a re-encode: picture and sound verified identical, and the click is gone. The disassembly of cpk_audi.o shows the PCM transfer address masked with and #-4, which fits, but the path from that mask to the click was not traced step by step.
── SOUND AND BITRATE ─────────────────────────────────────
The films shipped 8-bit until 13 September: the whole intro film used 242 distinct sample values. Not a hardware limit: the commercial game carries 2 channels of 16-bit at 32 kHz (128,000 B/s of audio) and peaks at 316,760 B/s over one second, of which only 188,760 is video. 16-bit costs 13.8 MB more disc (8.5%) and leaves the video peak and the largest video sample untouched: sound bytes cost CD bandwidth, not frame decode time, and tearing follows decode time. 32 kHz was tried; 16-bit mono at 22,050 Hz is now the one format for every film.
BEGIN, the intro: 274 kB/s on average, 291 kB/s at peak, on a 2x drive that reads 307 kB/s at most (300 KiB/s).
── 320x192, STRETCHED BY THE VDP2 ────────────────────────
Eleven of the seventeen films are 320x192 at the source, and the original game scales those films to 240 too. Encoding at 240 would spend the codec's bits on 48 lines that carry nothing: on BARRELS, native 192 is 15.4% smaller at identical settings.
The stretch is the NBG1 vertical coordinate increment: integer part at 18008CH bits 2-0, fraction at 18008EH bits 15-8, unsigned 3.8. VDP2 computes displayed Y = increment x V counter + scroll and discards the fraction, so an increment below 1 expands. 192/240 = 0.8 is 204.8/256, not representable; truncated to 204, floor(0.796875 x 239) = 190, and source line 191 would never be shown. So the fraction is rounded to 205 in integer arithmetic before slZoomNbg1(), which takes the increment. (slScrScaleNbg1() takes the magnification instead. Both write SGL's register mirror, which is re-sent to VDP2 at every vblank, so a direct register poke does not survive.) No filtering: one display line in five repeats the line above. Checked on the hardware capture: the repeated lines fall on the irregular pattern 205 predicts (gaps of 5, 5, 5, 5, 4...), not on 204's.
ZMCTL (180098H) only governs horizontal reduction, so a vertical expansion of an RGB555 bitmap needs no VRAM cycle-pattern change.
LIBCPK decodes straight into the displayed NBG1 bitmap: CPK_SetDecodeAddr() takes the destination and the line size as two independent arguments (sgl_cpk.h:614; AddrVram and SizeVramLine at 330-331). No intermediate buffer and no copy, which is what lets the films run without the cartridge.
Why not decode 240 lines anyway: a hardware A/B on 10 September, two discs identical except for BEGIN.CPK. Picture equal (0.11 dB apart over 662 frames), but the 240 pack tore 89 frames out of 2,903 against 7, and the tears follow the Cinepak sample size (88 of 89 above the median). Decoding into the displayed bitmap, a large sample lets the scan catch up with the writer; 192 lines are fewer lines to write.
── THE BARRELS: NAMED, THEN FIXED ────────────────────────
Devlog #7 named the mechanism, from three configurations that only make sense together: hardware with the cartridge, correct on entry and corrupt when firing; Ymir with the cartridge, corrupt on entry and correct when firing; no cartridge, corrupt on entry and correct when firing. Corruption does not self-repair. Nothing was corrupting the palette: a bank holds the colours of the last writer, and the barrels and the fired-shot effects were contending for the same bank. Three fixes shipped before that all went looking for a corrupter.
The fix that held (397.ao.1, merged just before the devlog #8 capture): a bank freed in the middle of a walk and handed to another palette gave the new tenant's colours to a sprite already in the list. Each bank and sub-slot is now stamped with the ordinal of the last walk that drew from the slot; a claim prefers a slot not drawn from this frame and falls back only when there is no other, never one sprite fewer. Same room, same gunfire: 21 frames turned teal in 6 seconds in devlog #7 (cartridge in), 0 in 186 frames in devlog #8 (no cartridge). The chant opens a bird portal and no longer recolours the barrels either.
── THE REST OF THE GAME ──────────────────────────────────
The Directoire stone (Abe's HandStone, RuptureFarms P15 C02) is enabled and shows the factory map: 99 Mudokons still at work, 28 in Zulag 1. The bell-song stone turned out to be a progression block behind the same gate. GameSpeak has the animations back, with ABESPEAK.BAN split per phrase. Quit returns to the BIOS; key remap; mono. And the robot's beam is on screen, three attempts later (below).
── THE NOISY LINE (EARLIER BUILD) ────────────────────────
The save/load part of the devlog #8 video comes from a build a few hours older, with a dotted line across the menu (like previous captures). The CPU was writing CRAM while VDP2 was reading CRAM for the current line: about 260 words a frame from the draw path (shade re-burn, copy on claim), PalSetData and the halo ramp, with no vblank guard. Every sprite palette write now lands in a shadow copy with one dirty bit per 256-byte block, and the shadow is copied to CRAM from SRL::Core::OnVblank, before SGL's frame change. The build of the main run carries the fix.
── FIXED SINCE THE DEVLOG #8 CAPTURE ─────────────────────
GameSpeak (416.ao.1): the voice ran ahead of Abe's mouth. Without the cartridge, each phrase's file (ABESPK0-9.BAN, 6 to 59 sectors) is read on demand by the synchronous loader, and the ten call sites started the voice before that read: about 165 to 520 ms with the game loop stopped. The files now load first, and voice and animation start on the same tick.
Walking back several screens with a possessed Slig (413.ao.1, 414.ao.1): a fatal error, the loader unable to place a 16 KiB block (MUDWORK.BND) with 913,664 of the resource heap's 933,192 bytes in use. Without the cartridge that heap is whatever LWRAM leaves, so the 4 KB CRAM shadow above came out of the heap one for one: the shadow moved to HWRAM .bss (3,072 B, banks 2-7 only). And every animation had been keeping a private decode buffer (about 11 KB each; Abe plus one Slig = 22,124 B), now replaced by one shared 16 KiB scratch, sized from a measurement: the largest write over the 35,019 compressed frames of the 14 levels is 13,104 B. The Mudokon whose Init had been silently refused on that screen is back, and the grenade blast and the gibs seem to be back too.
── TWO THINGS THAT WENT WRONG ────────────────────────────
1. Twenty builds of movie work, checked on emulators only. The console refused all twenty: a reboot loop where the GT logo should play, and no exception screen. SRL's Types::Event reserved capacity one and grew by reallocating (free the old buffer, copy, then publish begin/end/capacity in three non-atomic stores), while Invoke() walks the same vector for Core::OnVblank from the vblank interrupt. The second subscriber, CPK_VblIn, arrives when CinepakPlayer::Initialize runs at the first movie: a realloc racing a 60 Hz ISR. On hardware the engage is CD-bound, hundreds of vblanks of exposure; emulators return CD reads instantly and never lose the race. Patched locally in SRL (srl_event.hpp): the Event constructor reserves four slots, so no realistic subscriber count reallocates, and push_back writes the element before advancing end. A fix for the class, not for one instance. Note: I'm using an old version of SRL, and did not pull latest updates.
2. The robot's beam, three times.
Missing from every disc until 11 September. Promoting the beam's cel to 8bpp (for the additive blend) grows the palette; the old converter wrote the grown palette into a slot too small, and the overflow landed on both FrameHeaders of SPLINE.BAN, which then read w=0 h=0 depth=0. Animation::Init returns 0 before vram_alloc or PalAlloc: no counter moved, no warning fired, nothing was logged, and the beam simply never appeared, on all 13 LVLs that carry SPLINE.BAN. A census of the whole game (14 LVLs, 3,044 Anim chunks) finds exactly two overflowing records (the other, BLOOD.BAN, only dirtied the tail of a palette; the frames decode identically), and tools/check_pack.py now re-reads every regenerated pack in the build (~7 s).
Then erased by two fixes. The red cel is 9x9, with three rows of core for six of black, and the cel size IS the segment size on screen (ZapLine.cpp:190-210, field_14_w = frameW - 1). Removing the black, by transparency (394.ao.1) or by cropping (396.ao.1), leaves a 3 px dotted thread: on PSX 280 segments overlap into a solid bolt, while the Saturn's 84 segments no longer touch.
And turned green by a third: a vertical falloff meant to replace the black took the tints from CLUT indices believed free, which held original colours shared by both cels, and the red core turned green. Disabled in 400.ao.3.
── STILL BROKEN, NAMED ───────────────────────────────────
- Fidelity check.
- Pressing start while dying.
- Optimisation.
- The rest of the game has not been tested on the console yet.
- The in-game menu leaves some elements lit, unlike the original.
- The white fades: the original's are near-instant; here the white holds while the next screen loads.
── WITH THANKS ───────────────────────────────────────────
The SegaXtreme Discord.
@TrekkiesUnite118: tools and advice on the movie encoding.
@carlos24_: TrueMotion S tests.
@Cerbero: a reproducible fatal error.
@slygamer: the devlog #7 route, eight minutes run twice over (once clean, once carrying every gauge in the build), general testing, and the Discord exchanges.
@Wesker: general testing, and the Discord exchanges.