3.8 Media and loaders¶
This is the subsystem that gets code into the machine and gets state, pictures
and sound back out of it. Almost none of it is emulated hardware. A real
Spectrum has a tape port and an SD card; it has no notion of a .sna file, and
nothing inside it can write a PNG. These loaders exist because a developer
needs a way in and a way out, so they are host-side conveniences that reach
directly into the emulator's RAM, registers and MMU rather than going through
any bus the guest can see.
Everything under this heading lives in src/core/, is driven from the frontend
(a CLI flag at startup, a menu item later) and drives Emulator — never the
reverse. Only two things here are hardware the guest could tell apart from the
real article: the real-time tape signal, which arrives on the EAR line exactly
as an analogue tape would, and the SD card's SPI transport, which is a device
the guest's own driver talks to. Both are called out below.
What jnext can read and write¶
| Format | In | Out | Where | Notes |
|---|---|---|---|---|
.nex |
yes | yes | nex_loader.*, nex_saver.* |
Next-native. V1.0–V1.3 (V1.3 gated, see below) |
.sna |
yes | yes | sna_loader.*, sna_saver.* |
Reads 48K and 128K; writes 48K only |
.szx |
yes | yes | szx_loader.*, szx_saver.* |
Writes only 48K/128K/+3; refuses Next |
.z80 |
yes | — | z80_loader.* |
v1/v2/v3, 48K and 128K |
.tap |
yes | yes | tap_loader.*, tap_saver.* |
Save is a ROM SA-BYTES trap |
.tzx |
yes | — | tzx_loader.* |
Wraps ZOT (third_party/zot) |
.wav |
yes | — | wav_loader.* |
Real-time only, no fast path |
.rzx |
yes | yes | rzx_player.*, rzx_recorder.* |
Input recording, not state |
| raw binary | yes | — | Emulator::inject_binary |
--inject + --inject-org/--inject-pc |
Format selection is by file extension: for --load in src/main.cpp
around line 890, and again in the GUI's file dialogs and in the headless
snapshot writer. There is no content sniffing, and an extension jnext does not
recognise is a hard error naming the supported set rather than a guess at what
the file might be.
The snapshot and program loaders all follow the same two-phase shape.
load(path) parses and validates into member state; apply(emu) writes that
state into the machine. Splitting the two means a malformed file fails before
the machine has been touched, leaving the running session intact. Several
loaders additionally define their parse and decompression logic inline in the
header — z80_loader.h, tap_loader.h, nex_loader.h, tap_saver.h — for
one specific reason: it lets test tiers that cannot link jnext_core exercise
the real parser rather than a copy of it. See
chapter 4.
Tapes: two completely different mechanisms¶
Loading from tape is the one place where the emulator offers you a choice between fidelity and speed, so it is worth being clear about what the choice actually is. On real hardware a game takes minutes to load, the border stripes as it goes, and the loader chatters through the speaker. jnext can reproduce all of that, or it can skip it entirely and have the program in memory almost instantly. Those are two independent code paths that share nothing.
Fast load is a ROM trap. When ROM is paged into slot 0 and PC reaches the
48K ROM's LD-BYTES entry at 0x0556, the run loop hands the next block
straight to memory and skips the routine altogether — no tape signal is ever
generated. .tap and .tzx both support this. .wav cannot, because a WAV is
just audio samples with no block structure to extract. The gate is a PC match,
which has one consequence worth knowing: a custom or turbo loader that never
enters the ROM routine is never intercepted, and real time is then the only way
it will load.
Saving works the same way in reverse. --tape-save arms TapSaver, which
traps SA-BYTES at 0x04C2 and appends a TAP block. That trap is gated on a
ROM identity check as well as on the PC, because other ROMs legitimately
execute code at 0x04C2 — a plain PC gate fired ten times during an ordinary
NextZXOS boot.
Real-time playback (--tape-realtime) is the honest one. The loader drives
the EAR bit per T-state and the ROM's own loading routine decodes it, so the
border stripes and the timing are produced by the same code that produces them
on hardware. All three tape formats support it. The EAR signal is also routed
into the audio mixer, which is why you hear the loader as well as see it.
Getting BASIC to start loading is a third mechanism again. The phantom typist
in src/input/phantom_typist.h types LOAD "" for you once it can prove the
ROM's input loop is running — see 3.7 Input for how it decides
that. It is armed by load_tap() only; .tzx and .wav still fall back to a
fixed 100-frame delay.
NEX¶
.nex is the Next's native program container, and the most involved loader
here: four header versions, optional Layer 2 / LoRes / tilemode loading
screens, palettes, a Copper block, bank ordering and a CRC-32C.
Its oracle is Ped7g's nexload2.asm, not the distribution's own
nexload.asm. The distro loader refuses V1.3 outright, which makes nexload2
the only executable specification for that version. nex_loader.cpp cites it
line by line, and where the two loaders genuinely disagree — the palette-block
rule, the V1.3 delay model, the loading bar — the difference is documented as a
deliberate divergence rather than silently reconciled.
V1.3 is an experimental format and not officially supported (GH #228): the
user-facing entry points enforce V1.2 conformance. Emulator::load_nex()
refuses a header version above V1.2 unless
EmulatorConfig::allow_experimental_nex_v13 is set — the CLI sets it with
--experimental-nex-v1.3 (and refuses up front, exit 1, without it), the GUI
sets it per load after its warning dialog's Proceed. NexLoader itself stays
fully V1.3-capable — the gate lives at the entry seam, not in the loader — so
the V1.3 test corpus drives NexLoader::load()/apply() directly. The pure
policy predicate is nex_version_needs_v13_optin(); the pre-load version
query the entry points share is NexLoader::probe_version().
NexSaver writes V1.2, and its class comment enumerates what the format simply
cannot carry: no register file beyond PC and SP, no NextREG state, no MMU slots
0–5. Read that list before treating a NEX round trip as a snapshot, because it
is not one.
A directly loaded NEX can also keep its own file handle open and stream from
itself. extended_nex_host.* presents the host file to the guest as a
synthetic block-addressed SD extent, so NextZXOS's file APIs work against it.
RZX¶
RZX records a session, and it does so by recording inputs rather than
pictures. The file holds an embedded snapshot of the machine at the start plus,
for every frame that follows, the instruction count and every value the program
read back from an IN. Playing it back re-runs the original code: jnext feeds
the recorded values through the port-dispatch hook at the points the program
asks for them, so the program takes exactly the path it took when the recording
was made. The result is a very small file that reproduces a run exactly instead
of showing you a video of it — which is also why an RZX is a good bug report,
and why it only works at all if the emulator executes the same instructions in
the same order both times.
rzx.h holds the format, rzx_player.* and rzx_recorder.* the two
directions. jnext embeds a 48K SNA, which is the reason SnaSaver exists at
all.
Media out¶
| What | Flag / UI | Code |
|---|---|---|
| PNG screenshot | --delayed-screenshot, File ▸ Save Screenshot |
src/platform/screenshot.* |
| WAV of the mixer | --wav-record |
src/audio/audio_recorder.* |
| DAC activity CSV | --dac-trace |
src/audio/dac_trace_recorder.* |
| MP4 with audio | --record, File ▸ Record MPEG4 Video |
src/core/video_recorder.* |
Screenshots are always written at double height — each framebuffer row is
emitted twice — so that a 640×256 frame becomes a 640×512 PNG with square
pixels and the aspect ratio a viewer expects.
--delayed-screenshot-layers narrows a capture by clearing layer enables in
the renderer rather than by masking the result, so the layers that remain still
follow NR 0x15 priority; see 3.3 Video.
Video recording needs FFmpeg on the host. jnext writes raw ARGB frames and
raw stereo PCM to temporary files during the run, so the hot loop pays no
encoding cost, and shells out once at stop, trying libx264, mpeg4 and
libopenh264 in that order. The command line is built for two dialects — POSIX
sh and Windows CreateProcess — and both are compiled everywhere, so both
stay unit-testable on any host.
The SD card is two independent things¶
jnext talks to the same .img file in two entirely different ways, at two
different times, for two different consumers. The split looks redundant until
you notice that the two consumers want opposite things.
src/core/sd_rom_extractor.* is a host-side FAT32 reader. Before the
machine can start, jnext needs the ROM images that on real hardware live as
files on the card, and there is no guest yet to ask for them — so the emulator
opens the .img as a filesystem and pulls them out by path:
/MACHINES/NEXT/48.rom, enNxtmmc.rom, enNextMf.rom. It is read-only,
handles MBR + FAT32-LBA only, and does short-name lookup only, which is all
that job needs.
src/peripheral/sd_card.cpp is emulated hardware. Once the machine is
running, the guest wants a card, not a filesystem: NextZXOS carries its own
FAT driver and expects to do the filesystem work itself, over SPI, one 512-byte
block at a time. That class implements an SD card in SPI mode behind the
SpiDevice interface and serves those blocks over ports 0xE7/0xEB — see
3.6 Peripherals. It knows nothing about FAT32.
The two never talk to each other: one serves the host at init time, the other
the guest at run time. The card is opened read-write and guest writes persist,
so booting the machine mutates the image; --sdcard-readonly makes the guest
see a write-protected card instead.
Provisioning is a third, separate concern. sdcard_provisioner.* locates or
downloads the canonical image and re-clusters a copy via fat32_image.*,
because the shipped 1 GB image has too few clusters to be a spec-valid FAT32
and the Next firmware's own FatFs — correctly — rejects it.