Projektstruktur: game/-Package angelegt, README ergaenzt

Laufzeit-Code vom Dev-Werkzeug getrennt:
- game/: das eigentliche Spiel, startbar per `python -m game [--once]`.
  - formats.py  (war tools/kellogg_formats.py) -- Asset-Parser, Kern-Bibliothek
  - audio.py    (war tools/tfmx_audio.py)      -- TFMX-Musik-Wrapper
  - intro.py    (war tools/intro_sequence.py)  -- Boot-/Intro-Sequenz
  - tfmx_player/(war tools/tfmx_player/)        -- MIT-C-Renderer
  - __init__.py / __main__.py fuer `python -m game`
- tools/: nur noch Dev-Werkzeuge (dat_extract, pcc_to_png, render_assets),
  importieren jetzt aus game.formats.
- README.md: Ueberblick, Start, Struktur, Voraussetzungen, Rechtliches.
- .gitignore: tfmx_player-Binary-Pfad auf game/ nachgezogen.

Intro laeuft unveraendert (headless-Smoke-Test `python -m game --once` exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-24 00:29:03 +02:00
co-authored by Claude Opus 4.8
parent 3fe92d01b8
commit 85404c91f4
15 changed files with 133 additions and 15 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Tony & Friends in Kellogg's Land -- plattformunabhaengiges Remake.
Laufzeit-Package des Spiels. Enthaelt:
formats.py -- Parser/Decoder fuer die Original-Assets aus raw/PCKELL.DAT
(PCC-Bilder, BOB-Sprites, ICO-Tilesets, MAP-Level, Paletten).
audio.py -- Wrapper um den TFMX-Musik-Renderer (tfmx_player/).
intro.py -- die Boot-/Intro-Sequenz (Rauser -> Factor5 -> Kellogg's ->
Titelbild), erster spielbarer Meilenstein.
tfmx_player/-- MIT-lizenzierter C-Renderer fuer die TFMX-Musik.
Start: python -m game [--once]
"""
+10
View File
@@ -0,0 +1,10 @@
"""Startpunkt des Spiels: `python -m game [--once]`.
Spielt aktuell die Intro-Sequenz. Hier kommt spaeter die Ablaufsteuerung
(Intro -> Hauptmenue -> Weltkarte -> Gameplay) rein; bis dahin delegiert es
direkt an die Intro-Szene.
"""
from .intro import main
if __name__ == '__main__':
main()
+111
View File
@@ -0,0 +1,111 @@
"""
Duenner Python-Wrapper um das MIT-lizenzierte `tfmx_player/render_tfmx`
(siehe game/tfmx_player/LICENSE, Copyright Peter Fors) -- rendert ein
TFMX-Musikstueck (<NAME>.TFX + <NAME>.SAM aus PCKELL.DAT) einmalig zu einer
WAV-Datei und cached das Ergebnis, damit die Intro-Sequenz die Musik per
pygame.mixer abspielen kann.
Warum Subprocess statt reinem Python-Reimplement: TFMX ist ein komplexes
Amiga-Tracker-Format (siehe tfmx.h, ~95KB reine Player-Logik). Das
MIT-lizenzierte C-Referenztool duerfen wir direkt nutzen (im Gegensatz zum
lizenzlosen C#-Referenzprojekt fuer PCX/BOB/MAP, das wir bewusst nur als
Format-Wissen genommen und selbst neu geschrieben haben). Fuer Musik-
Wiedergabe ist der Sound selbst das Ziel, nicht der Decoder-Code -- ein
funktionierender, korrekt lizenzierter Renderer ist hier der pragmatische
Weg statt TFMX in Python neu zu erfinden.
Gerenderte WAVs sind urheberrechtlich das Original-Spielmaterial (Musik),
werden daher NICHT ins Git-Repo committed (wie raw/ und extracted_dat/) --
liegen nur lokal im Cache-Verzeichnis.
MEHRERE SONGS PRO MODUL (Fund 22.07.2026 abends, Stefans Hinweis "das Spiel
hat noch wesentlich mehr Lieder"): ein einzelnes TFX-Modul kann intern
MEHRERE Songs buendeln (tfmx.h's v_songs[]/v_songs_count, ausgewaehlt per
start_song-Index). Stichprobe per Debug-Tool ergab:
TITEL.TFX -> 3 Songs (song0/1 lang/loopend >15s, song2 ~8.0s)
TITEL2.TFX -> 2 Songs (song0 ~2.04s = Rauser-Bling, song1 ~2.40s = Factor5-
Brausen -- Laenge passt zu Stefans "nur ein bisschen laenger
als der Rauser-Sound")
ONGAME2.TFX -> 14 Songs (>15s-Tracks vermutlich Level-Musik, song3/11/12
sehr kurz (0.16-1.36s) vermutlich Jingles/Stinger, song2/13
~10.3s). Noch nicht im Detail zugeordnet -- fuer die
spaetere Gameplay-Musik relevant, siehe NOTES.md.
render_wav() nimmt jetzt optional `song_index` entgegen und reicht ihn als
5. CLI-Arg an render_tfmx durch. Der Cache-Dateiname enthaelt den Song-Index
NUR wenn er nicht der Default (0/None) ist, damit bereits vorhandene Caches
fuer Song 0 (z.B. TITEL.wav) gueltig bleiben.
"""
import os
import subprocess
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_PLAYER_DIR = os.path.join(_HERE, 'tfmx_player')
_BINARY = os.path.join(_PLAYER_DIR, 'render_tfmx')
_PROJECT_ROOT = os.path.dirname(_HERE)
_CACHE_DIR = os.environ.get('KELLOGG_AUDIO_CACHE', os.path.join(_PROJECT_ROOT, 'audio_cache'))
def _ensure_binary():
if os.path.exists(_BINARY):
return
subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True)
def render_wav(container, name, seconds=180, force=False, song_index=None):
"""container: kellogg_formats.DATContainer; name: z.B. 'TITEL' (ohne Extension).
song_index: optional -- welcher der ggf. mehreren in <name>.TFX gebuendelten
Songs gerendert werden soll (siehe Modul-Docstring). None/0 = Default-
Verhalten wie bisher (erster/einziger Song). Gibt den Pfad zur gerenderten
(gecachten) WAV-Datei zurueck.
WICHTIG (Fix 22.07.2026 abends): `seconds` ist nur noch eine Sicherheits-
OBERGRENZE, keine Ziel-Laenge! render_tfmx erkennt das natuerliche Song-
Ende/den Loop-Punkt selbst (tfmx.h real_song_end) und schneidet dort ab --
z.B. ist TITEL (song 0) tatsaechlich ~109s lang, ONGAME2 (song 0) ~123s,
TITEL2 song0 (Rauser-Sting) nur ~2.04s. Vorher wurde hart bei `seconds`
abgeschnitten. Der Default hier (180s) ist bewusst grosszuegig ueber die
laengsten bekannten Stuecke hinaus gewaehlt, damit nichts abgeschnitten
wird; im Normalfall stoppt render_tfmx laengst vorher von selbst.
ACHTUNG Cache: der Dateiname ist `<name>.wav` (song_index None/0) bzw.
`<name>_song<N>.wav` fuer N>0. Wird `seconds` kleiner gewaehlt als die
echte Songlaenge, MUSS `force=True` gesetzt werden, sonst liefert diese
Funktion einen ggf. laenger/kuerzer gerenderten alten Cache-Treffer
zurueck, egal was `seconds` diesmal sagt."""
os.makedirs(_CACHE_DIR, exist_ok=True)
suffix = f'_song{song_index}' if song_index else ''
out_path = os.path.join(_CACHE_DIR, f'{name}{suffix}.wav')
if os.path.exists(out_path) and not force:
return out_path
_ensure_binary()
tfx_data = container.entries[name + '.TFX']
sam_data = container.entries[name + '.SAM']
tfx_path = os.path.join(_CACHE_DIR, f'{name}.TFX')
sam_path = os.path.join(_CACHE_DIR, f'{name}.SAM')
with open(tfx_path, 'wb') as f:
f.write(tfx_data)
with open(sam_path, 'wb') as f:
f.write(sam_data)
cmd = [_BINARY, tfx_path, sam_path, out_path, str(seconds)]
if song_index:
cmd.append(str(song_index))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f'render_tfmx failed for {name} (song_index={song_index}): {result.stderr}')
return out_path
if __name__ == '__main__':
# Kleiner CLI-Test: python3 -m game.audio <DAT_PATH> <NAME> [seconds] [song_index]
sys.path.insert(0, _PROJECT_ROOT)
from game.formats import DATContainer
dat_path = sys.argv[1]
name = sys.argv[2]
seconds = int(sys.argv[3]) if len(sys.argv) > 3 else 20
song_index = int(sys.argv[4]) if len(sys.argv) > 4 else None
c = DATContainer(dat_path)
path = render_wav(c, name, seconds, force=True, song_index=song_index)
print(f'rendered: {path}')
+304
View File
@@ -0,0 +1,304 @@
'''
Shared decode library for Kellogg's Tony & Friends asset formats.
Ported (not copied) from the C# reference project
https://github.com/movAX13h/tony-and-friends-in-kelloggs-land (no LICENSE file,
therefore reimplemented from scratch in Python using their format docs as a guide).
Container: PCKELL.DAT holds all 178 assets, footer-indexed (see DATContainer).
'''
import struct
import os
from collections import namedtuple
# ---------------------------------------------------------------------------
# Container (PCKELL.DAT)
# ---------------------------------------------------------------------------
class DATContainer:
def __init__(self, path):
with open(path, 'rb') as f:
self.data = f.read()
self.entries = {} # filename -> bytes
self._parse()
def _parse(self):
data = self.data
num_entries = struct.unpack_from('<H', data, len(data) - 4)[0] + 1
offset = struct.unpack_from('<i', data, len(data) - 8)[0]
index_offset = offset
raw = []
for _ in range(num_entries):
name_len = struct.unpack_from('<H', data, offset)[0]
offset += 2
filename = data[offset:offset + name_len].decode('ascii', errors='replace')
offset += name_len
e_offset = struct.unpack_from('<i', data, offset)[0]
offset += 4
raw.append([filename, e_offset])
for i, e in enumerate(raw):
length = (raw[i + 1][1] if i + 1 < len(raw) else index_offset) - e[1]
self.entries[e[0]] = data[e[1]:e[1] + length]
def extract_all(self, out_dir):
os.makedirs(out_dir, exist_ok=True)
for name, payload in self.entries.items():
with open(os.path.join(out_dir, name), 'wb') as f:
f.write(payload)
# ---------------------------------------------------------------------------
# Palette (from a .PCC / PCX file's trailing 256-color block)
# ---------------------------------------------------------------------------
def get_pcx_palette(pcc_bytes):
'''Returns list of 256 (r,g,b) tuples from a PCX v5 file's end-of-file palette.'''
marker = pcc_bytes[-769]
if marker != 0x0C:
raise ValueError('PCX palette marker (0x0C) not found')
pal_bytes = pcc_bytes[-768:]
return [tuple(pal_bytes[i:i+3]) for i in range(0, 768, 3)]
def load_pcc(pcc_bytes):
'''Decode a .PCC (= plain PCX v5, 8bpp, RLE, own trailing 256-color
palette) via Pillow's battle-tested PCX decoder instead of a hand-rolled
RLE parser -- see git history 2026-07-22 (commits 5efba4b/62af15e/3410a53)
for why: TWO from-scratch RLE implementations silently produced visibly
wrong pixels (shifted/duplicated content) for full-screen 320x200 images
(KARTE.PCC, KELLOGGS.PCC) despite being "verified" -- Pillow avoids that
whole class of bug entirely.
Returns (width, height, indices, palette):
indices -- flat list[int] of palette indices, len width*height, row-major
palette -- list of 256 (r,g,b) tuples (this file's OWN embedded palette,
not any global one)
'''
from PIL import Image
import io
img = Image.open(io.BytesIO(pcc_bytes))
img.load()
if img.mode != 'P':
raise ValueError(f'expected palette (P) mode PCX, got {img.mode}')
width, height = img.size
indices = list(img.getdata())
raw_pal = img.getpalette() # flat [r,g,b, r,g,b, ...], may be shorter than 768
raw_pal = (raw_pal + [0] * 768)[:768]
palette = [tuple(raw_pal[i:i+3]) for i in range(0, 768, 3)]
return width, height, indices, palette
def pcc_to_rgba(pcc_bytes, transparent_index=None):
'''Convenience wrapper around load_pcc(): returns (width, height, rgba_bytes)
ready for e.g. pygame.image.frombuffer(rgba, (w,h), 'RGBA') or PIL Image.frombytes.
If transparent_index is given, that palette index becomes alpha=0.'''
width, height, indices, palette = load_pcc(pcc_bytes)
out = bytearray(width * height * 4)
for i, idx in enumerate(indices):
r, g, b = palette[idx & 0xFF]
a = 0 if (transparent_index is not None and idx == transparent_index) else 255
out[i*4:i*4+4] = bytes([r, g, b, a])
return width, height, bytes(out)
def get_bob_palette(entries, name):
'''name = basename without extension, e.g. 'TONY' for TONY.BOB.
Implements the palette-selection rules from Form1.cs (BOB case).'''
pcc_name = name + '.PCC'
if pcc_name in entries:
return list(get_pcx_palette(entries[pcc_name]))
if len(name) == 1:
# the ants have names like A.BOB .. O.BOB
return list(get_pcx_palette(entries['ANTS.PCC']))
pal = list(get_pcx_palette(entries['W2.PCC']))
pal[0] = (0, 0, 0)
return pal
def get_world_palette(entries, world):
'''world = 'W1'|'W2'|'W3'. Implements ICO/MAP palette rule (addW2Palette):
W2.PCC carries 16 shared colors (indices 16..31) used for animations/items
across all worlds, so W1/W3 palettes borrow that slice from W2.'''
pal = list(get_pcx_palette(entries[world + '.PCC']))
if world != 'W2':
w2 = get_pcx_palette(entries['W2.PCC'])
for i in range(16):
pal[16 + i] = w2[16 + i]
return pal
# ---------------------------------------------------------------------------
# BOB (animated sprites: self-modifying x86 'draw code' + embedded pixel data)
# ---------------------------------------------------------------------------
CopyInstr = namedtuple('CopyInstr', ['ega_page', 'offset', 'data'])
def _parse_bob_executable(data):
'''Interprets the tiny x86 instruction stream used to blit pixels.
Returns list of CopyInstr, or None if an unrecognized opcode is hit.'''
instrs = []
pc = 0
ega_page = -1
n = len(data)
while True:
if pc >= n:
return None
op = data[pc]
if op == 0x03: # add si, cx (0x03 0xF1)
if data[pc+1] != 0xF1:
return None
pc += 2
elif op == 0xCB: # retf
pc += 1
break
elif op in (0x56, 0x58, 0x5E, 0x50): # push si / push ax / pop si / push ax
pc += 1
elif op == 0xEE: # out dx, al -> advance EGA page (cycles 0..3)
ega_page = (ega_page + 1) & 3
pc += 1
elif op == 0xD0: # rol al, 1 (0xD0 0xC0)
if data[pc+1] != 0xC0:
return None
pc += 2
elif op == 0x8A: # mov cl, ah/bl/bh (no-op for our purposes)
if data[pc+1] not in (0xCC, 0xCB, 0xCF):
return None
pc += 2
elif op == 0xC6:
sub = data[pc+1]
if sub == 0x84: # mov byte ptr [si+AAAA], BB
offset = data[pc+2] | (data[pc+3] << 8)
const = data[pc+4]
pc += 5
instrs.append(CopyInstr(ega_page, offset, bytes([const])))
elif sub == 0x44: # mov byte ptr [si+AA], BB
offset = data[pc+2]
const = data[pc+3]
pc += 4
instrs.append(CopyInstr(ega_page, offset, bytes([const])))
else:
return None
elif op == 0xC7:
sub = data[pc+1]
if sub == 0x44: # mov word ptr [si+AA], BBBB
offset = data[pc+2]
const = data[pc+3] | (data[pc+4] << 8)
pc += 5
instrs.append(CopyInstr(ega_page, offset, bytes([const & 0xFF, (const >> 8) & 0xFF])))
elif sub == 0x84: # mov word ptr [si+AAAA], BBBB
offset = data[pc+2] | (data[pc+3] << 8)
const = data[pc+4] | (data[pc+5] << 8)
pc += 6
instrs.append(CopyInstr(ega_page, offset, bytes([const & 0xFF, (const >> 8) & 0xFF])))
else:
return None
else:
return None
return instrs
BOB_STRIDE = 84
class BobFrame:
__slots__ = ('width', 'height', 'pixels') # pixels: list of palette indices, width*height, -1 = transparent
def __init__(self, width, height):
self.width = width
self.height = height
self.pixels = [-1] * (width * height)
def set(self, x, y, idx):
if 0 <= x < self.width and 0 <= y < self.height:
self.pixels[y * self.width + x] = idx
def parse_bob(data):
'''Returns list of BobFrame (palette-index pixel grids, -1 = transparent/unset).'''
frames = []
pos = 0
n = len(data)
while pos < n:
header = data[pos:pos+14]
if len(header) < 14:
raise ValueError(f'truncated header at {pos}')
width = struct.unpack_from('<h', header, 6)[0]
height = struct.unpack_from('<h', header, 8)[0]
ptr_seg_len = struct.unpack_from('<h', header, 10)[0]
pos += 14
pointers_data = data[pos:pos+ptr_seg_len]
pos += ptr_seg_len
last_instruction = struct.unpack_from('<h', pointers_data, len(pointers_data) - 2)[0]
next_frame_pos = pos + last_instruction
exec_segment = data[pos:next_frame_pos]
pos = next_frame_pos
instrs = _parse_bob_executable(exec_segment)
if instrs is None:
raise ValueError(f'failed to parse exec segment for frame {len(frames)} (len={len(exec_segment)})')
frame = BobFrame(width, height)
for instr in instrs:
for i, byte in enumerate(instr.data):
p = instr.offset + i
x = (p % BOB_STRIDE) * 4 + instr.ega_page
y = p // BOB_STRIDE
idx = byte - 0x80
frame.set(x, y, idx)
frames.append(frame)
if pos != n:
raise ValueError(f'missed {n - pos} extra bytes at end of file')
return frames
# ---------------------------------------------------------------------------
# ICO (16x16 tiles, EGA scrambled pixel order)
# ---------------------------------------------------------------------------
def parse_ico(data):
'''Returns list of 16x16 palette-index grids (flat lists, len 256).'''
num_tiles = (len(data) - 1) // 256
tiles = []
ptr = 0
for _ in range(num_tiles):
grid = [0] * 256
for y in range(16):
for page in range(4):
for x in range(4):
k = data[ptr]
ptr += 1
col = x * 4 + page
grid[y * 16 + col] = k - 128
tiles.append(grid)
return tiles
# ---------------------------------------------------------------------------
# MAP (grid of tile refs + collision type, big-endian!)
# ---------------------------------------------------------------------------
def parse_map(data):
if data[0:4] != b'TLE1':
raise ValueError('MAP signature TLE1 not found')
width = struct.unpack_from('>h', data, 4)[0]
height = struct.unpack_from('>h', data, 6)[0]
unknown = struct.unpack_from('>h', data, 8)[0]
if unknown != 9:
raise ValueError(f'unexpected constant {unknown} (expected 9)')
pos = 10
cells = [] # row-major, len width*height, each (tile, type)
for _ in range(width * height):
value = struct.unpack_from('>H', data, pos)[0]
pos += 2
tile = value & 0x1FF
ctype = value >> 9
cells.append((tile, ctype))
if pos != len(data):
raise ValueError(f'missed {len(data) - pos} extra bytes at end of MAP')
return width, height, cells
+770
View File
@@ -0,0 +1,770 @@
#!/usr/bin/env python3
"""
Boot-/Intro-Sequenz von "Tony & Friends in Kellogg's Land" -- erster
spielbarer (bzw. hier: zuschaubarer) pygame-Meilenstein des Remakes.
Reihenfolge + Timing wurde am 22.07.2026 durch eine echte DOSBox-Screenshot-
Serie vermessen, siehe NOTES.md Abschnitt "Boot-Sequenz vermessen". Kurzfassung:
1. Rauser-Advertainment-Karte (Komposit aus RAUSER1+2+3.PCC) auf WEISSEM
Grund. RAUSER1 ("RAUSER"-Box) faehrt von LINKS ein, RAUSER3 (blaue Box
mit "!") faehrt von OBEN ein, RAUSER2 ("ADVERTAINMENT") steht von Anfang
an fest. Sobald beide Teile ihre Endposition erreicht haben, spielt ein
kurzer Sound-Sting (TITEL2-Song 0 -- 2.04s natuerliche Laenge, siehe
tfmx_audio-Docstring zu den mehreren Songs pro Modul). Danach faedet der
WEISSE Hintergrund kurz zu GRAU um, haelt kurz, dann schwarze Pause.
KORREKTUR-GESCHICHTE (Aufloesungs-Bug, zwei Anlaeufe):
v1: Rauser-Assets bei 0.5x per smoothscale verkleinert -- Ergebnis war
verwaschen/unscharf (Stefans erste Meldung "Aufloesung ist
schlecht").
v2 (falscher Fix): Annahme war, die Verkleinerung selbst sei der
Fehler -- also ASSET_DISPLAY_SCALE auf 1.0 (native Groesse ohne
jede Skalierung) gesetzt. Sah in DIESEM Container scharf aus, war
aber ein NEUER Bug: bei nativer Groesse (RAUSER1+RAUSER3 = 198px
Breite) ist das Logo auf der 320px-Leinwand riesig -- Stefan hat
nie "zu gross" gemeldet, weil das schon in v1 unter dem Verwaschen
unterging.
v3 (echter Fix, 22.07.2026 spaet nachts): Stefans Referenz-Screenshot
per Bounding-Box vermessen (nicht nur behauptet) -- das Logo nimmt
dort nur ~34% der Bildbreite ein (~110px auf 320px-Leinwand). Die
urspruengliche 0.5x-GROESSE war also die ganze Zeit richtig
(182+16=198px * 0.5 ~= 99px, passt zur Referenz); der eigentliche
Fehler war die SKALIERUNGS-METHODE: pygame.transform.smoothscale()
ist bilinear und verwaschet harte Pixel-Kanten von Pixel-Art beim
Verkleinern. Fix: ASSET_DISPLAY_SCALE bleibt 0.5, aber
pygame.transform.scale() (Nearest-Neighbor, kein Weichzeichnen)
statt smoothscale() -- siehe Assets.pcc_surface_scaled(). Per
Bounding-Box-Nachmessung des eigenen Renders bestaetigt: 33.0%
Breite / 13.6% Hoehe vs. Referenz 34.5% / 16.6% -- nah dran, nicht
mehr der 62%-Breite-Fehlgriff von v2.
2. kurze schwarze Pause
3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
das "Geister"-Doppellogo ist Teil des Assets selbst). KORREKTUR
(22.07.2026, spaeter Abend -- Stefans Meldung: "beim factor 5 hast ein
Level-Sound genommen ... der factor 5 Sound ist so ein Brausen, geht nur
ein bisschen laenger als der Rauser-Sound, das Bild wird ca. 5 Sekunden
eingeblendet"): die vorige Version hat faelschlich ONGAME2-Song 0
(~123s!) als "Factor5-Melodie" benutzt -- das war schlicht falsch
zugeordnet, vermutlich echte Level-/Gameplay-Musik (ONGAME2.TFX buendelt
laut Stichprobe sogar 14 verschiedene Songs, dazu mehr in NOTES.md).
Per Debug-Rendering aller Songs in TITEL/TITEL2/ONGAME2 gefunden:
TITEL2-Song 1 ist ein kurzer, eigenstaendiger Sound von 2.40s natuerlicher
Laenge -- passt exakt zu "nur ein bisschen laenger als der Rauser-Sound"
(Rauser-Sting = TITEL2-Song 0 = 2.04s). Das ist jetzt der Factor5-Sound.
WICHTIG (ehrlich, nicht nur behauptet): ich kann Audio nicht selbst
abhoeren, die Zuordnung ist eine Indizien-Schlussfolgerung aus der
Songlaenge (passt sehr gut), nicht durch Reinhoeren verifiziert -- falls
es beim Testen doch nicht "brausen" klingt, bitte melden, dann probieren
wir TITEL-Song 2 (~8.0s) oder einen der kurzen ONGAME2-Songs (3/11/12).
Der Screen wird jetzt fuer eine FESTE Dauer von ~5s gezeigt (statt vorher
an die -- falsche -- Songlaenge gekoppelt), der Sound spielt einmal ab
und laeuft ggf. vor Screen-Ende aus (er ist ja nur 2.4s lang).
4. kurze schwarze Pause
5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter
faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt-
rot ein (~3s).
6. kurze schwarze Pause
7. Titelbild "TONY & FRIENDS in Kellogg's Land" -- die 4 Quadranten KELL256A
(oben links) / B (oben rechts) / C (unten links) / D (unten rechts) sind
je 160x240 PCC-Nativaufloesung, werden aber nur bei **halber Groesse**
(160x120) in ein 2x2-Raster gezeichnet: A|B oben, C|D unten -> 320x240
Gesamtbild. Per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme
verifiziert. Reveal-Effekt: obere Haelfte (A|B) waechst von oben (Hoehe
0->120), untere Haelfte (C|D) waechst von unten (Hoehe 0->120) -- treffen
sich in der Mitte. Waehrend dieser Reveal-Phase ist die untere Haelfte
erst graustichig/entsaettigt MIT Scanlines ueberzogen, die sich danach in
einer zweiten Phase zu vollen Farben aufloesen (Stefans "Screenshot
4/5/6"-Effekt). HINWEIS: diese Quadranten nutzen denselben
Downscale-Mechanismus (smoothscale halbe Hoehe) wie die Rauser-Assets
vor dem obigen Fix -- hier bisher KEINE Bildschaerfe-Beschwerde von
Stefan, deshalb unangetastet gelassen. Falls das Titelbild auch mal als
"verwaschen" gemeldet wird: gleicher Fix-Ansatz (native Groesse zeichnen,
nur einmal ganzzahlig hochskalieren) anwendbar.
Alle Nicht-BOB/ICO/MAP-Assets werden per kellogg_formats.load_pcc() (Pillows
PCX-Decoder) aus PCKELL.DAT gelesen -- kein eigener RLE-Code mehr (siehe
Regressions-Warnung in pcc_to_png.py).
Rauser-Layout und Fade-Text-Bounding-Box sind aus den echten Screenshots per
Augenmass/Index-Analyse bestimmt (siehe tools/analyze_kelloggs.py-Fund:
Indizes 34/42/43/44/45 in KELLOGGS.PCC = "praesentiert"-Text, bbox x71-241
y108-141) -- nicht 100% pixelidentisch zum Original, aber nah dran. Kann bei
Bedarf spaeter nachjustiert werden.
HANG-FIX (23.07.2026 -- Stefans Meldung "nur noch ein schwarzes Bild,
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
haengt"): war real und per Xvfb-Test verifiziert, kein Bedienfehler. Ursache:
tfmx_audio.render_wav() rendert TFMX-Musik per BLOCKIERENDEM subprocess.run()
-- bei einem frischen, ungecachten Song (v.a. TITEL, ~109s) dauert das
reproduzierbar mehrere -zig Sekunden (gemessen: ~14s), waehrend derer die
pygame-Eventloop NICHT lief -- das Betriebssystem haelt das Fenster dann fuer
"nicht reagierend" (schwarz eingefroren, Desktop blitzt beim Verschieben
durch). Fix: alle drei benoetigten Songs werden jetzt VOR der eigentlichen
Sequenz einmalig in preload_all_audio() gerendert, mit sichtbarem, responsivem
Ladebildschirm (render_audio_with_loading() -- Rendering laeuft in einem
Thread, Hauptthread pumpt weiter Events). run_once() greift danach nur noch
auf fertige WAV-Pfade zu, nie mehr Live-Rendering im Hauptloop.
Aufruf: python3 -m game [--once] (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
"""
import sys
import os
import time
import threading
from collections import deque
from .formats import DATContainer, load_pcc
from . import audio as tfmx_audio
import pygame
try:
import numpy as np
HAVE_NUMPY = True
except ImportError:
HAVE_NUMPY = False
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'raw', 'PCKELL.DAT'))
WINDOW_W, WINDOW_H = 960, 720
BG_COLOR = (0, 0, 0)
# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
PRAESENTIERT_FADE_SECONDS = 2.5
# KORREKTUR #2 (22.07.2026 spaet nachts, Stefans Referenz-Screenshot
# vermessen): 1.0 (native Groesse) war KEIN Aufloesungs-Fix, sondern ein
# neuer Bug -- ich hatte die Blur-Ursache falsch diagnostiziert. Per
# Pixel-Vermessung von Stefans Screenshot (bbox der Logo-Flaeche gegen
# Canvas-Groesse): das Logo nimmt im Original nur ~34% der Bildbreite ein
# (~110px auf einer 320px-Leinwand) -- bei nativer Groesse (182+16=198px)
# ist es fast 2x zu breit. Die vorige 0.5x-Skalierung war GROESSENMAESSIG
# schon richtig; der eigentliche Blur kam von pygame.transform.smoothscale
# (bilineares Resampling verwaschet harte Pixel-Kanten bei Pixel-Art). Fix:
# 0.5x beibehalten, aber mit pygame.transform.scale (Nearest-Neighbor, kein
# Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled().
ASSET_DISPLAY_SCALE = 0.5
# Rauser-Timing (siehe Docstring)
RAUSER_SLIDE_SECONDS = 0.9
RAUSER_HOLD_SECONDS = 1.3
RAUSER_FADE_TO_GREY_SECONDS = 0.4
RAUSER_GREY_HOLD_SECONDS = 0.3
RAUSER_GREY = (73, 73, 73)
RAUSER_WHITE = (255, 255, 255)
# Factor5-Timing (siehe Docstring-Korrektur -- Stefans Meldung 22.07.2026
# abends): feste Anzeigedauer statt (falsch) an eine 123s-Melodie gekoppelt.
FACTOR5_HOLD_SECONDS = 5.0
# Titelbild-Timing (siehe Docstring)
TITLE_HEIGHT_REVEAL_FRACTION = 0.55 # Anteil der Wipe-Dauer fuer das Hoehenwachstum
TITLE_WIPE_SECONDS = 2.3
def indices_to_surface(width, height, indices, palette):
'''Baut ein pygame.Surface (RGB) aus Palette-Indizes + (r,g,b)-Liste.'''
buf = bytearray(width * height * 3)
for i, idx in enumerate(indices):
r, g, b = palette[idx & 0xFF]
buf[i*3:i*3+3] = bytes([r, g, b])
surf = pygame.image.frombuffer(bytes(buf), (width, height), 'RGB')
return surf.convert()
def make_border_bg_transparent(surface, threshold=235):
'''Fix fuer Stefans Meldung (22.07.2026 abends): "der Rauser-Text fadet
nicht mit aus, bleibt hart umrandet". Ursache: die Rauser-PCC-Assets haben
KEINEN Alphakanal -- ihr papierweisser Hintergrund sitzt als hartes
Rechteck um die eigentliche Box-Grafik (RAUSER-Schriftzug/ADVERTAINMENT/
"!"-Box). Auf weissem Bildhintergrund faellt das nicht auf, aber sobald
die Szene zu Grau ueberblendet, bleibt dieses Rechteck stur weiss stehen
statt mit auszufaden. Ein v1-Versuch mit set_colorkey() wurde verworfen,
weil er auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig machte.
Fix: Flood-Fill vom Bildrand aus ueber alle "papierweissen" Pixel (alle
Kanaele >= threshold) -- NUR die vom Rand aus zusammenhaengende Flaeche
wird transparent gemacht. Isolierte weisse Pixel MITTEN im Motiv (z.B.
weisse Buchstaben im RAUSER-Schriftzug) beruehren den Bildrand nicht und
bleiben deshalb unangetastet. Ergebnis: eine Surface mit echtem
Alphakanal, die beim Blit auf einen sich veraendernden Hintergrund
(weiss->grau) sauber durchscheint, statt eine harte Kante zu zeigen.'''
w, h = surface.get_size()
surf = surface.convert_alpha()
px = pygame.PixelArray(surf)
def is_bg(x, y):
color = surf.unmap_rgb(px[x, y])
return color.r >= threshold and color.g >= threshold and color.b >= threshold
seen = bytearray(w * h)
dq = deque()
def consider(x, y):
if not seen[y * w + x] and is_bg(x, y):
seen[y * w + x] = 1
dq.append((x, y))
for x in range(w):
consider(x, 0)
consider(x, h - 1)
for y in range(h):
consider(0, y)
consider(w - 1, y)
while dq:
cx, cy = dq.popleft()
if cx + 1 < w:
consider(cx + 1, cy)
if cx - 1 >= 0:
consider(cx - 1, cy)
if cy + 1 < h:
consider(cx, cy + 1)
if cy - 1 >= 0:
consider(cx, cy - 1)
del px
alpha = pygame.surfarray.pixels_alpha(surf)
for x in range(w):
for y in range(h):
if seen[y * w + x]:
alpha[x, y] = 0
del alpha
return surf
class Assets:
def __init__(self, dat_path):
self.container = DATContainer(dat_path)
def pcc(self, name):
'''name z.B. "RAUSER1" (ohne .PCC). Gibt (w,h,indices,palette) zurueck.'''
return load_pcc(self.container.entries[name + '.PCC'])
def pcc_surface(self, name):
w, h, indices, palette = self.pcc(name)
return indices_to_surface(w, h, indices, palette)
def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE, cut_border_bg=False):
'''Skaliert BEIDE Achsen um scale. KORREKTUR #2 (22.07.2026 spaet
nachts, siehe Docstring-Korrektur ganz oben): scale ist wieder 0.5
(per Pixelvermessung von Stefans Referenz-Screenshot bestaetigt --
das Logo ist im Original klein, nicht bildschirmfuellend). Der
eigentliche Blur-Fix ist pygame.transform.scale statt smoothscale:
scale() ist Nearest-Neighbor (keine Weichzeichnung), smoothscale()
war bilinear und hat bei 0.5x harte Pixel-Kanten der Pixel-Art
verwaschen -- DAS war Stefans "Aufloesung ist schlecht"-Bug, nicht
die Groesse selbst.
cut_border_bg=True macht den vom Bildrand aus erreichbaren papierweissen
Hintergrund transparent (siehe make_border_bg_transparent) -- fuer die
Rauser-Teile, damit sie beim Fade zu Grau nicht als harte weisse
Kaesten stehen bleiben.'''
surf = self.pcc_surface(name)
if cut_border_bg:
surf = make_border_bg_transparent(surf)
if scale == 1.0:
return surf
w, h = surf.get_size()
new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
return pygame.transform.scale(surf, new_size)
def pcc_surface_half_height(self, name):
'''Skaliert NUR die Hoehe um 0.5, Breite bleibt nativ -- fuer die
Titelbild-Quadranten KELL256A/B/C/D (160x240 -> 160x120). ACHTUNG:
das ist KEINE gleichmaessige Skalierung wie bei den Rauser-Assets --
per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme verifiziert
(siehe Docstring ganz oben). Nearest-Neighbor (pygame.transform.scale)
statt smoothscale aus dem gleichen Grund wie bei den Rauser-Assets
(siehe pcc_surface_scaled) -- konsistent scharf.'''
surf = self.pcc_surface(name)
w, h = surf.get_size()
new_size = (w, max(1, round(h * 0.5)))
return pygame.transform.scale(surf, new_size)
def fit_scale(src_w, src_h, max_w, max_h):
return min(max_w / src_w, max_h / src_h)
def blit_scaled_centered(screen, surface, window_w, window_h, bg_color=BG_COLOR):
scale = fit_scale(surface.get_width(), surface.get_height(), window_w, window_h)
new_size = (max(1, int(surface.get_width() * scale)), max(1, int(surface.get_height() * scale)))
scaled = pygame.transform.scale(surface, new_size)
x = (window_w - new_size[0]) // 2
y = (window_h - new_size[1]) // 2
screen.fill(bg_color)
screen.blit(scaled, (x, y))
return scale, x, y
def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
'''Laeuft seconds lang, ruft pro Frame screen_update_fn(t_norm 0..1) auf.
Bricht per ESC/Klick vorzeitig ab (gibt True zurueck wenn User quit will).'''
start = time.monotonic()
while True:
now = time.monotonic()
t = (now - start) / seconds if seconds > 0 else 1.0
if t >= 1.0:
screen_update_fn(1.0)
pygame.display.flip()
return False
for event in pygame.event.get():
if event.type == pygame.QUIT:
return True
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return True
screen_update_fn(t)
pygame.display.flip()
clock.tick(fps)
def render_audio_with_loading(screen, clock, container, name, song_index, label):
'''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
Ladebildschirm zeichnet.
FIX (23.07.2026 -- Stefans Meldung "kommt nur noch ein schwarzes Bild,
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
haengt"): Ursache gefunden und per Test verifiziert, nicht nur vermutet --
render_wav() ruft render_tfmx als BLOCKIERENDEN subprocess.run() auf, ohne
dass die pygame-Eventloop weiterlaeuft. Bei einem frischen, noch nicht
gecachten Song (v.a. TITEL, ~109s Musik) dauert dieser eine Call
reproduzierbar mehrere -zig Sekunden (per Xvfb-Testlauf auf aria-wohnung
gemessen: ~14s Totalblockade direkt nach Factor5, bevor das Kellogg's-Logo
kommt). Ohne pygame.event.get()/display.flip() waehrend dieser Zeit stuft
das Betriebssystem das Fenster als "nicht reagierend" ein -- exakt das
beschriebene Bild (Inhalt bleibt schwarz stehen, beim Verschieben des
Fensters blitzt der darunterliegende Desktop durch, weil nichts neu
gezeichnet wird).
Fix: render_wav laeuft in einem Thread; der Haupt-Thread bleibt die ganze
Zeit responsiv (Events pumpen, Ladehinweis zeichnen, display.flip()) --
egal wie lange das Rendering dauert (auch beim allerersten Start ohne
Cache oder wenn build.sh den render_tfmx-Binary neu kompilieren muss).
Gibt (wav_path_or_None, quit_requested) zurueck. wav_path ist None wenn
das Rendering fehlschlaegt (z.B. kein C-Compiler verfuegbar) -- die
Sequenz laeuft dann wie bisher an dieser Stelle stumm weiter, kein
Absturz.'''
result = {}
def worker():
try:
result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index)
except Exception as exc:
result['error'] = exc
thread = threading.Thread(target=worker, daemon=True)
thread.start()
try:
font = pygame.font.SysFont(None, 28)
except Exception:
font = None
dots = 0
last_dot_tick = time.monotonic()
while thread.is_alive():
for event in pygame.event.get():
if event.type == pygame.QUIT:
return None, True
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return None, True
now = time.monotonic()
if now - last_dot_tick > 0.4:
dots = (dots + 1) % 4
last_dot_tick = now
screen.fill(BG_COLOR)
if font is not None:
text = font.render(f'Lade {label}{"." * dots}', True, (200, 200, 200))
screen.blit(text, (WINDOW_W // 2 - text.get_width() // 2, WINDOW_H // 2 - text.get_height() // 2))
pygame.display.flip()
clock.tick(30)
thread.join()
if 'error' in result:
print(f'[intro_sequence] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr)
return None, False
return result.get('path'), False
def preload_all_audio(screen, clock, assets):
'''Rendert VOR der eigentlichen Sequenz alle drei benoetigten TFMX-Songs
einmalig (siehe render_audio_with_loading-Docstring) -- danach ist jeder
run_once()-Durchlauf (auch der allererste!) garantiert freeze-frei, weil
zur Laufzeit nur noch aus dem WAV-Cache abgespielt wird statt live zu
rendern. Ueberspringt das Preloading komplett, wenn gar kein Audio-Device
verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
if not pygame.mixer.get_init():
return {}, False
jobs = [
(('TITEL2', 0), 'Rauser-Sound'),
(('TITEL2', 1), 'Factor5-Sound'),
(('TITEL', None), 'Titelmusik'),
]
audio_paths = {}
for key, label in jobs:
name, song_index = key
path, quit_requested = render_audio_with_loading(screen, clock, assets.container, name, song_index, label)
audio_paths[key] = path
if quit_requested:
return audio_paths, True
return audio_paths, False
def build_letterboxed(surface, canvas_w=320, canvas_h=240):
'''Zentriert ein kleineres PCC-Bild (z.B. 320x199/200) auf eine 320x240-
Leinwand mit schwarzen Balken -- wie im echten DOSBox-Fenster beobachtet.'''
canvas = pygame.Surface((canvas_w, canvas_h))
canvas.fill((0, 0, 0))
x = (canvas_w - surface.get_width()) // 2
y = (canvas_h - surface.get_height()) // 2
canvas.blit(surface, (x, y))
return canvas
def build_kelloggs_frame(w, h, indices, palette, fade_t):
'''fade_t: 0..1, faedet PRAESENTIERT_FADE_INDICES von blass (Richtung
Hintergrundweiss) zu ihrer echten Palettenfarbe.'''
dyn_pal = list(palette)
for idx in PRAESENTIERT_FADE_INDICES:
r, g, b = palette[idx]
# start: sehr blass (nah am weissen Logo-Hintergrund), Ende: echte Farbe
pale = (255, 245, 245)
nr = int(pale[0] + (r - pale[0]) * fade_t)
ng = int(pale[1] + (g - pale[1]) * fade_t)
nb = int(pale[2] + (b - pale[2]) * fade_t)
dyn_pal[idx] = (nr, ng, nb)
return indices_to_surface(w, h, indices, dyn_pal)
# ---------------------------------------------------------------------------
# Rauser: Slide-in-Animation + Sound-Sting (siehe Docstring-Korrektur)
# ---------------------------------------------------------------------------
def build_rauser_parts(assets):
'''Liefert die drei (bei 0.5x Nearest-Neighbor-Groesse, siehe Docstring-
Korrektur v3) Rauser-Teile + ihre Endpositionen auf einer 320x240-
Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter
mittig).'''
# cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen
# Hintergrund um jede Box transparent (nur die vom Bildrand aus
# zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher
# blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht
# mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen").
r1 = assets.pcc_surface_scaled('RAUSER1', cut_border_bg=True) # "RAUSER"-Box
r2 = assets.pcc_surface_scaled('RAUSER2', cut_border_bg=True) # "ADVERTAINMENT"
r3 = assets.pcc_surface_scaled('RAUSER3', cut_border_bg=True) # "!" auf blauer Box
total_w = r1.get_width() + r3.get_width()
total_h = r1.get_height() + r2.get_height()
x0 = (320 - total_w) // 2
y0 = (240 - total_h) // 2
r1_final = (x0, y0)
r3_final = (x0 + r1.get_width(), y0 - (r3.get_height() - r1.get_height()) // 2)
r2_final = (x0, y0 + r1.get_height())
return {
'r1': r1, 'r2': r2, 'r3': r3,
'r1_final': r1_final, 'r2_final': r2_final, 'r3_final': r3_final,
}
def render_rauser_slide(canvas, parts, slide_t):
'''slide_t: 0..1. r1 faehrt von links, r3 von oben ein; r2 steht fest.
Bei slide_t>=1 sind alle Teile an ihrer Endposition.'''
r1, r2, r3 = parts['r1'], parts['r2'], parts['r3']
ease = slide_t * slide_t * (3 - 2 * slide_t) # smoothstep, wirkt weniger linear/robotisch
r2_final = parts['r2_final']
canvas.blit(r2, r2_final)
r1_final_x, r1_final_y = parts['r1_final']
r1_start_x = -r1.get_width()
r1_x = int(r1_start_x + (r1_final_x - r1_start_x) * ease)
canvas.blit(r1, (r1_x, r1_final_y))
r3_final_x, r3_final_y = parts['r3_final']
r3_start_y = -r3.get_height()
r3_y = int(r3_start_y + (r3_final_y - r3_start_y) * ease)
canvas.blit(r3, (r3_final_x, r3_y))
def run_rauser(screen, clock, assets, audio_paths):
'''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will.
audio_paths kommt aus preload_all_audio() -- das Audio wird hier NICHT
mehr live gerendert (siehe render_audio_with_loading-Docstring zum
Hang-Bug, den das behebt), sondern nur noch aus dem fertigen WAV-Pfad
geladen (schnell, nie blockierend).'''
parts = build_rauser_parts(assets)
sting = None
# TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s), Song 1
# ist der Factor5-Sound (siehe Docstring).
sting_path = audio_paths.get(('TITEL2', 0))
if pygame.mixer.get_init() and sting_path:
try:
sting = pygame.mixer.Sound(sting_path)
except Exception as exc:
print(f'[intro_sequence] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr)
sting_played = False
def show_slide(t):
nonlocal sting_played
canvas = pygame.Surface((320, 240))
canvas.fill(RAUSER_WHITE)
render_rauser_slide(canvas, parts, t)
if t >= 1.0 and not sting_played:
sting_played = True
if sting is not None:
sting.play()
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE)
quit_requested = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide)
if quit_requested:
return True
# falls die Slide-Phase zu kurz war um sting_played zu triggern (sollte
# durch t>=1.0 im letzten Frame von wait_or_skip immer der Fall sein)
if not sting_played and sting is not None:
sting.play()
sting_played = True
def show_hold_white(_t):
canvas = pygame.Surface((320, 240))
canvas.fill(RAUSER_WHITE)
render_rauser_slide(canvas, parts, 1.0)
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE)
quit_requested = wait_or_skip(clock, RAUSER_HOLD_SECONDS, show_hold_white)
if quit_requested:
return True
def show_fade_grey(t):
bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3))
canvas = pygame.Surface((320, 240))
canvas.fill(bg)
render_rauser_slide(canvas, parts, 1.0)
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=bg)
quit_requested = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey)
if quit_requested:
return True
def show_hold_grey(_t):
canvas = pygame.Surface((320, 240))
canvas.fill(RAUSER_GREY)
render_rauser_slide(canvas, parts, 1.0)
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_GREY)
return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS, show_hold_grey)
# ---------------------------------------------------------------------------
# Titelbild: 2x2-Raster aus halbskalierten Quadranten + Scanline-Reveal
# (siehe Docstring-Korrektur -- NICHT 320x480!)
# ---------------------------------------------------------------------------
def build_title_quadrants(assets):
'''Liefert die 4 Titelbild-Quadranten bei halber Hoehe (160x120 statt
160x240 nativ) -- A/B oben, C/D unten. Per Pixel-Rekonstruktion gegen
eine echte DOSBox-Aufnahme verifiziert.'''
return {
'A': assets.pcc_surface_half_height('KELL256A'),
'B': assets.pcc_surface_half_height('KELL256B'),
'C': assets.pcc_surface_half_height('KELL256C'),
'D': assets.pcc_surface_half_height('KELL256D'),
}
def build_title_image(quads):
'''Komplettes, ruhendes Titelbild (320x240) aus den 4 halbskalierten
Quadranten im 2x2-Raster.'''
a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
w = a.get_width() + b.get_width()
h = a.get_height() + c.get_height()
canvas = pygame.Surface((w, h))
canvas.blit(a, (0, 0))
canvas.blit(b, (a.get_width(), 0))
canvas.blit(c, (0, a.get_height()))
canvas.blit(d, (a.get_width(), a.get_height()))
return canvas
def _scanline_desaturate(surface, intensity):
'''intensity 0..1: 0 = unveraendert, 1 = komplett entsaettigt + jede
zweite Zeile abgedunkelt (Stefans "Scanlines drueber"-Beobachtung waehrend
des Titelbild-Reveals, siehe Docstring). Braucht numpy; ohne numpy wird
intensity ignoriert (Bild bleibt scharf) statt abzustuerzen.'''
if intensity <= 0 or not HAVE_NUMPY:
return surface
arr = pygame.surfarray.array3d(surface).astype(np.float32) # (w,h,3)
gray = arr.mean(axis=2, keepdims=True)
out = arr * (1 - intensity) + gray * intensity
scan = np.ones((1, arr.shape[1], 1), dtype=np.float32)
scan[0, ::2, 0] = 1.0 - 0.5 * intensity
out = np.clip(out * scan, 0, 255).astype(np.uint8)
return pygame.surfarray.make_surface(out)
def render_title_wipe_frame(quads, t):
'''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION
waechst Hoehe (oben 0->120 von oben, unten 0->120 von unten). Danach loest
sich der Scanline/Graustufen-Effekt auf der unteren Haelfte auf (Stefans
Screenshot4->5->6-Beobachtung).'''
a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
half_h = a.get_height() # 120
full_w = a.get_width() + b.get_width() # 320
canvas = pygame.Surface((full_w, half_h * 2))
canvas.fill((0, 0, 0))
if t < TITLE_HEIGHT_REVEAL_FRACTION:
reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
h = max(0, int(half_h * reveal))
scan_intensity = 1.0
else:
h = half_h
scan_intensity = 1.0 - (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION)
if h > 0:
top_row = pygame.Surface((full_w, half_h))
top_row.blit(a, (0, 0))
top_row.blit(b, (a.get_width(), 0))
top_slice = top_row.subsurface((0, 0, full_w, h))
canvas.blit(top_slice, (0, 0))
bot_row = pygame.Surface((full_w, half_h))
bot_row.blit(c, (0, 0))
bot_row.blit(d, (a.get_width(), 0))
bot_slice = bot_row.subsurface((0, half_h - h, full_w, h)).copy()
bot_slice = _scanline_desaturate(bot_slice, scan_intensity)
canvas.blit(bot_slice, (0, half_h * 2 - h))
return canvas
def run_once(screen, clock, assets, audio_paths):
quit_requested = run_rauser(screen, clock, assets, audio_paths)
if quit_requested:
return True
# 2) schwarze Pause
screen.fill(BG_COLOR)
pygame.display.flip()
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 3) Factor5 (statisches Bild). KORREKTUR (22.07.2026 abends, siehe
# Docstring): NICHT mehr ONGAME2 (das war falsch zugeordnete Level-
# Musik), sondern TITEL2-Song 1 -- ein kurzer "Brausen"-Sound (~2.4s),
# der zur Beschreibung "nur ein bisschen laenger als der Rauser-Sound"
# passt. Screen-Dauer ist jetzt FEST (~5s), nicht mehr an die (falsche)
# 123s-Songlaenge gekoppelt.
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
factor5_path = audio_paths.get(('TITEL2', 1))
if pygame.mixer.get_init() and factor5_path:
try:
factor5_sound = pygame.mixer.Sound(factor5_path)
factor5_sound.play()
except Exception as exc:
print(f'[intro_sequence] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr)
def show_factor5(_t):
blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, FACTOR5_HOLD_SECONDS, show_factor5)
if quit_requested:
return True
# 4) schwarze Pause
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 5) Kellogg's-Logo + "praesentiert"-Palettenfade
# Titelmusik (TITEL Song 0, ~109s) setzt hier ein und laeuft geloopt
# weiter bis zum Ende der Sequenz (Original hat vermutlich durchgehende
# Titelmusik ueber Logo+Titelbild+Karte -- noch nicht per Referenz-
# Sichtung bestaetigt, aber plausibelste Annahme fuers erste v1).
titel_path = audio_paths.get(('TITEL', None))
if pygame.mixer.get_init() and titel_path:
try:
pygame.mixer.music.load(titel_path)
pygame.mixer.music.play(-1)
except Exception as exc:
print(f'[intro_sequence] Titelmusik konnte nicht geladen werden: {exc}', file=sys.stderr)
kw, kh, kidx, kpal = assets.pcc('KELLOGGS')
def show_kelloggs(t):
frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
letterboxed = build_letterboxed(frame)
blit_scaled_centered(screen, letterboxed, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
if quit_requested:
return True
# kurz mit vollem Text stehen lassen
quit_requested = wait_or_skip(clock, 1.0, lambda t: show_kelloggs(1.0))
if quit_requested:
return True
# 6) schwarze Pause
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 7) Titelbild -- 2x2-Quadranten-Reveal mit Scanline-Aufloesung (siehe
# Docstring-Korrektur)
quads = build_title_quadrants(assets)
def show_title_wipe(t):
canvas = render_title_wipe_frame(quads, t)
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, TITLE_WIPE_SECONDS, show_title_wipe)
if quit_requested:
return True
# danach: fertig zusammengesetztes Titelbild (320x240, siehe Korrektur) halten
title_img = build_title_image(quads)
def show_title_full(_t):
blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, 3.0, show_title_full)
return quit_requested
def main():
once = '--once' in sys.argv
pygame.mixer.pre_init(44100, -16, 2, 512)
pygame.init()
try:
pygame.mixer.init()
except pygame.error as exc:
# Kein Audio-Device vorhanden (z.B. headless VM/Container ohne
# Soundkarte) -- Sequenz laeuft stumm weiter statt abzustuerzen.
print(f'[intro_sequence] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr)
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)")
clock = pygame.time.Clock()
assets = Assets(DAT_PATH)
# Alle TFMX-Songs EINMALIG vor der eigentlichen Sequenz rendern (mit
# sichtbarem, responsivem Ladebildschirm) -- siehe render_audio_with_loading
# zum Hang-Bug, den das behebt. Danach greifen alle run_once()-Durchlaeufe
# nur noch auf fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
audio_paths, quit_requested = preload_all_audio(screen, clock, assets)
if not quit_requested:
quit_requested = run_once(screen, clock, assets, audio_paths)
while not quit_requested and not once:
quit_requested = run_once(screen, clock, assets, audio_paths)
if pygame.mixer.get_init():
pygame.mixer.music.stop()
pygame.quit()
if __name__ == '__main__':
main()
+49
View File
@@ -0,0 +1,49 @@
MIT License
Copyright (c) 2026 Peter Fors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
This project is a port of replayers from NostalgicPlayer
(https://github.com/neumatho/NostalgicPlayer), which is distributed under the
MIT License with the following notice:
MIT License
Copyright (c) 2023 Thomas Neumann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Builds render_tfmx, a tiny standalone harness around tfmx.h (see NOTICE.md
# in this directory for provenance/license). Produces ./render_tfmx which
# takes <mdat.tfx> <smpl.sam> <out.wav> [seconds] and writes a stereo
# 16-bit/44100Hz WAV rendering of the TFMX module.
set -e
cd "$(dirname "$0")"
gcc -std=gnu99 -O2 -Wall -Wno-unused-function -Wno-unused-variable -Wno-unused-parameter \
-o render_tfmx render_tfmx.c -lm
echo "built: $(dirname "$0")/render_tfmx"
+632
View File
@@ -0,0 +1,632 @@
// Copyright (c) 2026 Peter Fors
// SPDX-License-Identifier: MIT
//
// Amiga 500 Paula emulator for custom replayers.
//
// This is a hardware model, not a resampler. The channel mixer runs in the
// Paula clock domain (3546895 Hz PAL / 3579545 Hz NTSC). Each hardware
// channel has a period counter that, when it expires, latches the next 8-bit
// sample byte; between latches the channel holds that byte (the zero-order-
// hold staircase a real Paula produces). Volume is the real 6-bit PWM over a
// 64-clock window, not a multiply, so its quantization noise is reproduced.
// Channels 0+3 are summed to the left output, 1+2 to the right, hard-panned,
// in the Paula clock domain. The analog filter chain (always-on RC low-pass --
// ~4.4 kHz on A500, ~34 kHz on A1200 -- plus the switchable ~3.3 kHz LED
// Butterworth) runs at the Paula clock rate. Only the final stage decimates
// to the host rate, by box-filter integration of the Paula-clock samples that
// fall in each output window. Aliasing and quantization noise that a real
// Amiga produces are preserved.
//
// Paula has exactly four hardware channels (0..3). There is no software-
// mixer extension and no side bus: a real Amiga has no extra channel in its
// signal path. Every format with more than four voices built a mixed buffer
// on the CPU and DMA'd THAT through these four channels, so any such mixdown
// is the replayer's job and its output IS Paula channel sample data, <= 4
// channels, passing through this same hardware path.
//
// Output is ACCUMULATED into the caller's float buffer. The hardware output
// chain is modelled end to end, to the RCA jack, not just to the summer node:
//
// 1. Resistive averaging summer: the two channels on each side (0+3 left,
// 1+2 right) join through equal board resistors, so the per-side node
// is (ch_a + ch_b) / 2 (a ~6 dB attenuation).
// 2. The analog filter chain acts on that node: an always-on RC low-pass
// (~4.4 kHz on A500, ~34 kHz on A1200) plus the switchable ~3.3 kHz
// LED Butterworth.
// 3. Output buffer/amp: normalises int8 full scale to unity. The
// resistive divider's ~6 dB attenuation is preserved (not compensated)
// so the per-side level matches real hardware: a single full-scale
// channel lands at ~0.5, two correlated full-scale channels on the
// same side at ~1.0. No analog rail saturation is modelled -- at line
// out on a stock A500 the output op-amp runs with ~10 V of usable
// rail headroom against a ~1 V peak signal and never reaches its
// rails in practice. The output is then clamped to [-1, +1] purely
// as a digital safety guard for callers converting to fixed-point:
// small Butterworth step overshoot on transients (a few percent)
// cannot leak out as wrap/click noise after a (int16_t)(x * 32768)
// style cast. Absolute level is the host's concern.
//
// Host integration: the filter chain (always-on RC LP + switchable LED
// Butterworth + decimation anti-alias) runs IIR state at the Paula clock,
// and that state decays exponentially toward zero when channels go silent.
// Once any state slot crosses the float denormal threshold (~1.18e-38),
// every subsequent multiply touching it is denormal-slow on x86 (roughly
// two orders of magnitude); a single mix can blow past the host's audio
// buffer duration -- audible as underrun. Adding per-sample denormal-
// prevention bias inside the filter inner loops would cost a fadd per
// stage per Paula clock (millions/sec), so the agreed convention is:
// THE HOST AUDIO THREAD MUST RUN WITH MXCSR FTZ+DAZ ENABLED. Any thread
// that calls paula_mix_frames is in scope. The standard recipe is
// #include <pmmintrin.h>
// _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);
// _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);
// at the top of the audio thread proc. MXCSR is per-thread on x86 so this
// must be set inside the thread, not once at program start.
#pragma once
#include <stdint.h>
#include <string.h>
#include <math.h>
// L+R packed double, used through the filter chain to halve biquad cost: all
// three filter stages use identical coefficients per side, only state differs,
// so each biquad line becomes one packed instruction (one packed FMA on
// x86-64-v3). GCC/Clang vector extension; arithmetic operators are overloaded
// to the right SIMD ops per -march.
typedef double paula_v2df __attribute__((vector_size(16)));
// Opt-in mixer profiler. Compiled in only when PAULA_PROFILE is defined, so
// normal builds carry zero footprint. Accumulates process CPU time spent
// strictly inside paula_mix_frames (not replayer tick work) and the number of
// frames produced; paula_profile_report() turns that into a realtime factor.
#ifdef PAULA_PROFILE
#include <stdio.h>
#include <time.h>
static double paula_profile_cpu_ns = 0.0;
static uint64_t paula_profile_frames = 0;
#endif
// Paula has exactly four hardware channels. Formats with more voices must
// CPU-mix down to <= 4 themselves; there is no extra channel here.
#define PAULA_NUM_CHANNELS 4
#define PAULA_PAL_CLOCK 3546895
#define PAULA_NTSC_CLOCK 3579545
// Real Paula audio DMA floor. The Hardware Reference Manual's period-124
// figure is the rate at which all four channels can DMA without the bus
// falling behind during display fetch; a single channel goes lower. The true
// hardware floor is period 113 -- the ProTracker/Soundtracker note table
// bottoms at exactly 113 (B-3) because that is where Paula stops. Clamping to
// 124 detunes the whole top octave flat (period 113 -> ~160 cents). All four
// hardware channels clamp to this.
#define PAULA_DMA_MIN_PERIOD 113
// Period accumulator fixed-point: one Paula clock advances the accumulator by
// PAULA_PERIOD_ONE; a channel consumes one sample byte every period_q of
// these. period_q is integer-exact for the Paula register path and fractional
// for the Hz path, so both keep exact pitch.
#define PAULA_PERIOD_SHIFT 16
#define PAULA_PERIOD_ONE (1ull << PAULA_PERIOD_SHIFT)
struct paula_channel {
int8_t *sample;
uint32_t length; // bytes (becomes loop_start+loop_length after first wrap)
uint32_t loop_start; // bytes
uint32_t loop_length; // bytes, 0 => one-shot
uint32_t pos; // current byte index into sample
uint64_t period_q; // Paula clocks per sample byte, Q16
uint64_t period_acc; // period accumulator, Q16
int8_t *pending_sample; // deferred switch on next wrap (Paula AUDxLC trick)
uint32_t pending_pos;
uint32_t pending_length;
int8_t cur; // latched sample byte (zero-order-hold output)
uint16_t volume; // 0..64 Amiga scale
uint8_t pwm_cnt; // 0..63 volume-PWM phase
uint8_t active;
uint8_t muted;
uint8_t has_pending;
uint8_t backwards; // 1 -> step DOWN through sample (DBP E3, etc.)
};
// Amiga model. Selects the always-on post-DAC RC low-pass corner: ~4.4 kHz on
// A500 (the classic muffled top end), ~34 kHz on A1200 (bright but not brick-
// walled -- the slight roll into the top octave that real hardware has,
// neither aliasing brightness nor A500 muffling). The LED filter exists on
// both. Default is the A500.
#define PAULA_MODEL_A500 0
#define PAULA_MODEL_A1200 1
struct paula {
struct paula_channel ch[PAULA_NUM_CHANNELS];
int32_t sample_rate; // host output rate
int32_t clock; // Paula clock (PAL/NTSC), internal mix rate
int32_t samples_per_tick;
int32_t tick_offset;
int32_t model;
// Box-filter decimation from the Paula clock domain to the host rate.
// Each output sample averages the decim_step (Q16) Paula clocks that
// fall in its window; decim_phase carries the fraction across calls so
// the clock count alternates with no pitch drift.
uint64_t decim_step;
uint64_t decim_phase;
// Always-on 1-pole RC low-pass, at the Paula clock rate. Corner depends
// on model: ~4.4 kHz for A500, ~34 kHz for A1200. State is L+R packed.
double fixed_lp_a;
paula_v2df fixed_lp;
// Switchable LED filter: 2-pole Butterworth low-pass (~3.3 kHz,
// Q=1/sqrt(2)), at the Paula clock rate, RBJ bilinear coefficients.
// Driven by the replayer via paula_set_lp_filter; biquad state (TDF-II,
// L+R packed) persists across toggles so flips don't click.
int32_t lp_filter_on;
double led_b0;
double led_b1;
double led_b2;
double led_a1;
double led_a2;
paula_v2df led_z1;
paula_v2df led_z2;
// Decimation anti-alias low-pass: 8th-order Butterworth (4 cascaded RBJ
// biquads) at 0.45*host_rate, run in the Paula clock domain just before the
// rate drop. This is a resampler reconstruction filter, NOT modelled
// hardware: it bandlimits to below the host Nyquist so the box-average
// decimation cannot fold ultrasonic ZOH images down into the audible band.
// Keyed to host_rate, so it runs for both models; on the A500 the analog
// chain has already removed everything near Nyquist, making it a no-op.
// State is L+R packed per stage.
double aa_b0[4];
double aa_a1[4];
double aa_a2[4];
paula_v2df aa_z1[4];
paula_v2df aa_z2[4];
};
// [=]===^=[ paula_recalc ]=======================================================================[=]
// Recompute every rate-dependent coefficient from p->clock and
// p->sample_rate. The analog filters run at the Paula clock, so their
// coefficients are bilinear-transformed for that rate, not the host rate.
static void paula_recalc(struct paula *p) {
double fs = (double)p->clock;
double dt = 1.0 / fs;
// Always-on RC low-pass. Corner is model-dependent: A500 ~4.4 kHz,
// A1200 ~34 kHz. a = dt / (RC + dt).
double lp_fc = (p->model == PAULA_MODEL_A1200) ? 34000.0 : 4400.0;
double lp_rc = 1.0 / (2.0 * 3.14159265358979323846 * lp_fc);
p->fixed_lp_a = dt / (lp_rc + dt);
// LED filter: 2-pole Butterworth low-pass, ~3.3 kHz, Q = 1/sqrt(2),
// RBJ cookbook low-pass mapped via the bilinear transform at fs.
double fc = 3300.0;
double q = 0.70710678118654752440;
double w0 = 2.0 * 3.14159265358979323846 * fc / fs;
double cw = cos(w0);
double sw = sin(w0);
double alpha = sw / (2.0 * q);
double a0 = 1.0 + alpha;
p->led_b0 = ((1.0 - cw) * 0.5) / a0;
p->led_b1 = (1.0 - cw) / a0;
p->led_b2 = ((1.0 - cw) * 0.5) / a0;
p->led_a1 = (-2.0 * cw) / a0;
p->led_a2 = (1.0 - alpha) / a0;
// Decimation anti-alias: 8th-order Butterworth low-pass at 0.45*host_rate,
// mapped via RBJ bilinear at the Paula clock. The four sections carry the
// standard 8th-order Butterworth section Q's; cascaded DC gain is unity.
double aa_q[4] = {0.50979558, 0.60134489, 0.89997622, 2.56291545};
double aa_w0 = 2.0 * 3.14159265358979323846 * (0.45 * (double)p->sample_rate) / fs;
double aa_cw = cos(aa_w0);
double aa_sw = sin(aa_w0);
for(uint32_t st = 0; st < 4; ++st) {
double al = aa_sw / (2.0 * aa_q[st]);
double a0 = 1.0 + al;
p->aa_b0[st] = ((1.0 - aa_cw) * 0.5) / a0;
p->aa_a1[st] = (-2.0 * aa_cw) / a0;
p->aa_a2[st] = (1.0 - al) / a0;
}
// Box-filter decimation step: Paula clocks per host output sample, Q16.
p->decim_step = ((uint64_t)p->clock << PAULA_PERIOD_SHIFT) / (uint64_t)p->sample_rate;
}
// [=]===^=[ paula_init ]=========================================================================[=]
static void paula_init(struct paula *p, int32_t sample_rate, int32_t tick_rate_hz) {
memset(p, 0, sizeof(*p));
p->sample_rate = sample_rate;
p->clock = PAULA_PAL_CLOCK;
p->samples_per_tick = sample_rate / tick_rate_hz;
p->model = PAULA_MODEL_A500;
// Hard-panned: channels 0+3 -> left, 1+2 -> right (fixed Paula wiring).
paula_recalc(p);
}
// [=]===^=[ paula_set_clock ]====================================================================[=]
// Select the Paula clock (PAULA_PAL_CLOCK / PAULA_NTSC_CLOCK). Recomputes the
// rate-dependent coefficients. Default after paula_init is PAL.
static void paula_set_clock(struct paula *p, int32_t clock_hz) {
p->clock = clock_hz > 0 ? clock_hz : PAULA_PAL_CLOCK;
paula_recalc(p);
}
// [=]===^=[ paula_set_model ]====================================================================[=]
// Select the emulated machine. The always-on post-DAC RC low-pass corner
// changes with model (A500 ~4.4 kHz, A1200 ~34 kHz); the LED filter exists on
// both. Default is the A500.
static void paula_set_model(struct paula *p, int32_t model) {
p->model = (model == PAULA_MODEL_A1200) ? PAULA_MODEL_A1200 : PAULA_MODEL_A500;
paula_recalc(p);
}
// [=]===^=[ paula_set_lp_filter ]================================================================[=]
// Enable or disable the switchable Amiga LED filter (the power-LED-gated
// 2-pole low-pass). Replayers call this to mirror the module's own filter
// state. The always-on RC low-pass is not affected (A500 ~4.4 kHz, A1200
// ~34 kHz).
static void paula_set_lp_filter(struct paula *p, int32_t on) {
p->lp_filter_on = on ? 1 : 0;
}
// [=]===^=[ paula_set_period ]===================================================================[=]
// Amiga AUDxPER (DMA) period. All four hardware channels clamp to the real
// Paula DMA minimum period.
static void paula_set_period(struct paula *p, int32_t idx, uint16_t period) {
if(period != 0 && period < PAULA_DMA_MIN_PERIOD) {
period = PAULA_DMA_MIN_PERIOD;
}
if(period == 0) {
p->ch[idx].period_q = 0;
return;
}
p->ch[idx].period_q = (uint64_t)period << PAULA_PERIOD_SHIFT;
}
// [=]===^=[ paula_set_freq_hz ]==================================================================[=]
// Set a channel's DMA rate directly in Hz, for replayers that DMA a
// CPU-built mixdown buffer through a Paula channel (DigiBoosterPro, FaceThe-
// Music). The period is fractional in the Paula clock domain so pitch stays
// exact. No DMA period floor: mixdown rates are well above it anyway.
static void paula_set_freq_hz(struct paula *p, int32_t idx, uint32_t freq_hz) {
if(freq_hz == 0) {
p->ch[idx].period_q = 0;
return;
}
p->ch[idx].period_q = ((uint64_t)p->clock << PAULA_PERIOD_SHIFT) / (uint64_t)freq_hz;
}
// [=]===^=[ paula_set_volume ]===================================================================[=]
static void paula_set_volume(struct paula *p, int32_t idx, uint16_t volume) {
if(volume > 64) {
volume = 64;
}
p->ch[idx].volume = volume;
}
// Volume is passed in 0..256 range in NostalgicPlayer convention; divide to 0..64.
// [=]===^=[ paula_set_volume_256 ]===============================================================[=]
static void paula_set_volume_256(struct paula *p, int32_t idx, uint16_t volume) {
if(volume > 256) {
volume = 256;
}
p->ch[idx].volume = volume >> 2;
}
// [=]===^=[ paula_play_sample ]==================================================================[=]
static void paula_play_sample(struct paula *p, int32_t idx, int8_t *sample, uint32_t length) {
struct paula_channel *c = &p->ch[idx];
c->sample = sample;
c->length = length;
c->pos = (c->backwards && length > 0) ? (length - 1) : 0;
c->loop_start = 0;
c->loop_length = 0;
c->has_pending = 0;
c->pending_sample = 0;
c->period_acc = 0;
c->active = (sample != 0) && (length > 0);
c->cur = c->active ? sample[c->pos] : 0;
}
// [=]===^=[ paula_set_backwards ]================================================================[=]
// Set or clear the backwards-playback flag for a channel. Takes effect on the
// next paula_play_sample (which seeds pos at the high end) and reverses the
// per-byte advance direction.
static void paula_set_backwards(struct paula *p, int32_t idx, int32_t on) {
p->ch[idx].backwards = on ? 1 : 0;
}
// [=]===^=[ paula_set_pos ]======================================================================[=]
// Move the channel's read position to `byte_offset` within the current sample
// and re-latch the held byte. Used by effects like ProTracker 9xx (sample
// offset). Clamps to [0, length-1].
static void paula_set_pos(struct paula *p, int32_t idx, uint32_t byte_offset) {
struct paula_channel *c = &p->ch[idx];
if(c->sample == 0 || c->length == 0) {
c->pos = 0;
c->cur = 0;
return;
}
if(byte_offset >= c->length) {
byte_offset = c->length - 1;
}
c->pos = byte_offset;
c->cur = c->sample[byte_offset];
}
// [=]===^=[ paula_queue_sample ]=================================================================[=]
// If the channel is active, the new sample takes effect when the current one
// reaches length (Amiga "write AUDxLC/AUDxLEN mid-DMA"). If inactive, it
// starts immediately. Plays from sample[start_offset] for `length` bytes,
// then wraps using the channel's current loop_start / loop_length.
static void paula_queue_sample(struct paula *p, int32_t idx, int8_t *sample, uint32_t start_offset, uint32_t length) {
struct paula_channel *c = &p->ch[idx];
if(!c->active && sample != 0 && length > 0) {
c->sample = sample;
c->pos = start_offset;
c->length = start_offset + length;
c->has_pending = 0;
c->pending_sample = 0;
c->period_acc = 0;
c->active = 1;
c->cur = sample[start_offset];
return;
}
c->pending_sample = sample;
c->pending_pos = start_offset;
c->pending_length = start_offset + length;
c->has_pending = (sample != 0) && (length > 0);
}
// [=]===^=[ paula_set_loop ]=====================================================================[=]
static void paula_set_loop(struct paula *p, int32_t idx, uint32_t start, uint32_t length) {
struct paula_channel *c = &p->ch[idx];
c->loop_start = start;
c->loop_length = length;
}
// [=]===^=[ paula_mute ]=========================================================================[=]
static void paula_mute(struct paula *p, int32_t idx) {
p->ch[idx].active = 0;
}
// [=]===^=[ paula_ch_advance ]===================================================================[=]
// Consume one sample byte for a channel: step the read position one byte
// (forward or backward), apply the pending-sample swap / loop wrap / one-shot
// stop exactly as Paula DMA does, and re-latch the held byte.
__attribute__((always_inline))
static inline void paula_ch_advance(struct paula_channel *c) {
if(!c->backwards) {
uint32_t np = c->pos + 1;
if(np >= c->length) {
if(c->has_pending) {
c->sample = c->pending_sample;
np = c->pending_pos;
c->length = c->pending_length;
c->has_pending = 0;
c->pending_sample = 0;
} else if(c->loop_length > 0) {
uint32_t over = np - c->length;
np = c->loop_start + (over % c->loop_length);
c->length = c->loop_start + c->loop_length;
} else {
c->active = 0;
return;
}
}
c->pos = np;
} else {
if(c->pos == 0 || (c->loop_length > 0 && c->pos <= c->loop_start)) {
if(c->has_pending) {
c->sample = c->pending_sample;
c->length = c->pending_length;
c->pos = c->pending_length - 1;
c->has_pending = 0;
c->pending_sample = 0;
} else if(c->loop_length > 0) {
c->pos = c->loop_start + c->loop_length - 1;
} else {
c->active = 0;
return;
}
} else {
c->pos = c->pos - 1;
}
}
c->cur = c->sample[c->pos];
}
// [=]===^=[ paula_ch_sample ]====================================================================[=]
// Consume one Paula clock for a single channel: bump the period accumulator,
// advance the read position by as many bytes as the accumulator demands (may
// deactivate a one-shot channel), then return the PWM-gated sample value the
// channel contributes this clock. Returns 0.0 for a channel that is or just
// went inactive, so the caller's accumulator can stay branch-free.
// always_inline: called per Paula clock per active channel (~14M/s of audio),
// and the inliner's -O2 size budget refuses on its own.
__attribute__((always_inline))
static inline double paula_ch_sample(struct paula_channel *c) {
if(!c->active) {
return 0.0;
}
c->period_acc += PAULA_PERIOD_ONE;
while(c->period_acc >= c->period_q) {
c->period_acc -= c->period_q;
paula_ch_advance(c);
if(!c->active) {
return 0.0;
}
}
c->pwm_cnt = (uint8_t)((c->pwm_cnt + 1) & 63);
int32_t v = (c->pwm_cnt < c->volume) ? (int32_t)c->cur : 0;
return (double)v;
}
// [=]===^=[ paula_mix_frames ]===================================================================[=]
// Accumulates `frames` float stereo frames into `output`. Caller must
// pre-clear. The inner loop runs at the Paula clock; each output frame is the
// box-filter average of the Paula-clock samples in its window.
//
// L+R run packed as paula_v2df through the analog/AA chain: all three filter
// stages share coefficients across sides, only state differs, so each biquad
// line is one packed instruction (one packed FMA on x86-64-v3). The output
// safety clamp at the box-average store is also packed (one minpd, one
// maxpd).
static void paula_mix_frames(struct paula *p, float *output, int32_t frames) {
#ifdef PAULA_PROFILE
struct timespec prof_t0;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &prof_t0);
#endif
int32_t led = p->lp_filter_on;
paula_v2df fa = {p->fixed_lp_a, p->fixed_lp_a};
paula_v2df fl = p->fixed_lp;
paula_v2df lb0 = {p->led_b0, p->led_b0};
paula_v2df lb1 = {p->led_b1, p->led_b1};
paula_v2df lb2 = {p->led_b2, p->led_b2};
paula_v2df la1 = {p->led_a1, p->led_a1};
paula_v2df la2 = {p->led_a2, p->led_a2};
paula_v2df lz1 = p->led_z1;
paula_v2df lz2 = p->led_z2;
paula_v2df ab0[4];
paula_v2df aa1[4];
paula_v2df aa2[4];
paula_v2df az1[4];
paula_v2df az2[4];
for(uint32_t st = 0; st < 4; ++st) {
ab0[st] = (paula_v2df){p->aa_b0[st], p->aa_b0[st]};
aa1[st] = (paula_v2df){p->aa_a1[st], p->aa_a1[st]};
aa2[st] = (paula_v2df){p->aa_a2[st], p->aa_a2[st]};
az1[st] = p->aa_z1[st];
az2[st] = p->aa_z2[st];
}
paula_v2df two = {2.0, 2.0};
paula_v2df half = {0.5, 0.5};
// amp_gain normalises int8 full scale (128) to 1.0 and deliberately does
// NOT make up the resistive divider's 6 dB attenuation -- the per-side
// level then matches real hardware (single full-scale channel at ~0.5,
// two correlated full-scale channels on a side at ~1.0). Output is the
// box-filter average over the window (/n), clamped to [-1, +1] at the
// float store as a digital safety guard for fixed-point conversion.
paula_v2df amp = {1.0 / 128.0, 1.0 / 128.0};
uint64_t phase = p->decim_phase;
uint64_t dstep = p->decim_step;
// Active-channel working set, split by side. The selection predicate
// (active / unmuted / has sample / nonzero period) is stable within a
// mix call: only `active` can drop when a one-shot sample ends mid-call,
// which paula_ch_sample handles per channel. Splitting by side kills the
// per-Paula-clock "ci == 0 || ci == 3" branch -- each per-side scalar
// accumulator now stays in a register through its sweep. Output is bit-
// identical because pl and pr are separate accumulators with fixed
// channel assignments (0+3 -> pl, 1+2 -> pr): the per-side sum only
// depends on which channels are active, not on iteration order.
struct paula_channel *hw_l[PAULA_NUM_CHANNELS];
struct paula_channel *hw_r[PAULA_NUM_CHANNELS];
uint32_t nl = 0;
uint32_t nr = 0;
for(int32_t ci = 0; ci < PAULA_NUM_CHANNELS; ++ci) {
struct paula_channel *c = &p->ch[ci];
if(!c->active || c->muted || c->sample == 0 || c->period_q == 0) {
continue;
}
if(ci == 0 || ci == 3) {
hw_l[nl++] = c;
} else {
hw_r[nr++] = c;
}
}
for(int32_t i = 0; i < frames; ++i) {
phase += dstep;
uint32_t n = (uint32_t)(phase >> PAULA_PERIOD_SHIFT);
phase &= (PAULA_PERIOD_ONE - 1);
if(n == 0) {
n = 1;
}
paula_v2df s = {0.0, 0.0};
for(uint32_t k = 0; k < n; ++k) {
double pl = 0.0;
double pr = 0.0;
for(uint32_t j = 0; j < nl; ++j) {
pl += paula_ch_sample(hw_l[j]);
}
for(uint32_t j = 0; j < nr; ++j) {
pr += paula_ch_sample(hw_r[j]);
}
// Passive resistive averaging summer: the per-side filter node
// is (ch_a + ch_b) / 2, so it cannot exceed a single channel's
// full scale and the hardware path never clips.
paula_v2df x = (paula_v2df){pl, pr} * half;
// Always-on RC pole (model-dependent corner baked into fa).
fl = fl + (x - fl) * fa;
x = fl;
if(led) {
paula_v2df y = lb0 * x + lz1;
lz1 = lb1 * x - la1 * y + lz2;
lz2 = lb2 * x - la2 * y;
x = y;
}
// Downstream output buffer/amp: int8 -> unity normalisation.
// No rail saturation is modelled (see header for rationale).
x = x * amp;
// Anti-alias before the rate drop: 4 cascaded Butterworth biquads
// (TDF-II), L+R packed. Bandlimits below host Nyquist so the box-
// average decimation below cannot fold ultrasonic images down.
// RBJ low-pass identities baked in here: b1 = 2*b0 and b2 = b0,
// so only ab0[] is stored. Do not reuse this loop for a non-LP
// section -- it will silently produce wrong output.
for(uint32_t st = 0; st < 4; ++st) {
paula_v2df y = ab0[st] * x + az1[st];
az1[st] = two * ab0[st] * x - aa1[st] * y + az2[st];
az2[st] = ab0[st] * x - aa2[st] * y;
x = y;
}
s = s + x;
}
// Box-filter average over the window, then clamp to [-1, +1] as a
// digital safety guard so callers casting to fixed-point cannot get
// wrap/click from small Butterworth transient overshoot. Branchless
// packed -- one minpd, one maxpd via the GCC vector built-ins.
double inv = 1.0 / (double)n;
paula_v2df out = s * (paula_v2df){inv, inv};
paula_v2df hi = {1.0, 1.0};
paula_v2df lo = {-1.0, -1.0};
out = __builtin_ia32_minpd(out, hi);
out = __builtin_ia32_maxpd(out, lo);
output[2 * i] += (float)out[0];
output[2 * i + 1] += (float)out[1];
}
p->fixed_lp = fl;
p->led_z1 = lz1;
p->led_z2 = lz2;
for(uint32_t st = 0; st < 4; ++st) {
p->aa_z1[st] = az1[st];
p->aa_z2[st] = az2[st];
}
p->decim_phase = phase;
#ifdef PAULA_PROFILE
struct timespec prof_t1;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &prof_t1);
paula_profile_cpu_ns += (double)(prof_t1.tv_sec - prof_t0.tv_sec) * 1.0e9 + (double)(prof_t1.tv_nsec - prof_t0.tv_nsec);
paula_profile_frames += (uint64_t)frames;
#endif
}
#ifdef PAULA_PROFILE
// [=]===^=[ paula_profile_report ]===============================================================[=]
// Print the accumulated mixer cost as a realtime factor. Call once at exit.
static void paula_profile_report(int32_t sample_rate) {
if(paula_profile_frames == 0) {
fprintf(stderr, "paula_mix_frames: never called (this player has its own mixer, not paula.h)\n");
return;
}
double cpu_s = paula_profile_cpu_ns * 1.0e-9;
double audio_s = (sample_rate > 0) ? (double)paula_profile_frames / (double)sample_rate : 0.0;
double rt = (cpu_s > 0.0) ? audio_s / cpu_s : 0.0;
double core_pct = (audio_s > 0.0) ? 100.0 * cpu_s / audio_s : 0.0;
fprintf(stderr, "paula_mix_frames: %.3fs CPU for %.1fs audio -> %.1fx realtime (%.2f%% of one core)\n",
cpu_s, audio_s, rt, core_pct);
}
#endif
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) 2026 Peter Fors
// SPDX-License-Identifier: MIT
//
// Common interface every ported replayer exports. Each player's header declares
// a `struct player_api <name>_api` global that the test player (or any host)
// can iterate to auto-detect the right replayer for a file.
//
// Audio output is interleaved float stereo, nominal range [-1.0, 1.0]. Players
// ACCUMULATE into the caller's buffer (caller must pre-clear). They do NOT
// clip; the host is responsible for any final saturation, dithering, or
// conversion to the audio backend's native sample format.
#pragma once
#include <stdint.h>
// Optional file-loader callback used by players that read companion files
// (IFF SMUS instrument files, Face The Music external samples, etc.). Hosts
// that pass a loader implement `fetch` to resolve a logical name (e.g.
// "Instruments/Bass6.instr") to a heap-allocated byte buffer + length. The
// player calls free() on the returned pointer when done.
struct player_loader {
void *ctx;
uint8_t *(*fetch)(void *ctx, const char *name, uint32_t *out_len);
};
struct player_api {
const char *name;
const char **extensions; /* null-terminated list of lowercase extensions, no dot */
void *(*init)(void *data, uint32_t len, int32_t sample_rate);
void (*free)(void *state);
void (*get_audio)(void *state, float *output, int32_t frames);
// Optional: when non-null and the host has a loader for sibling files,
// the host should prefer this entry point. Players that don't need
// external files leave this null and the host falls back to init().
void *(*init_ex)(void *data, uint32_t len, int32_t sample_rate, struct player_loader *loader);
};
// [=]===^=[ player_get_audio_s16 ]===============================================================[=]
// Convenience wrapper for hosts that want signed-16 PCM out. Drives the
// player's float get_audio into the caller-supplied scratch buffer (must hold
// at least frames * 2 floats), clears it first, then converts with hard
// saturation into `output` (frames * 2 int16 stereo samples). The scratch is
// caller-owned so the hot path never allocates; reuse the same buffer across
// calls.
#include <string.h>
static void player_get_audio_s16(struct player_api *api, void *state, int16_t *output, float *scratch, int32_t frames) {
int32_t samples = frames * 2;
memset(scratch, 0, (size_t)samples * sizeof(float));
api->get_audio(state, scratch, frames);
for(int32_t i = 0; i < samples; ++i) {
float v = scratch[i] * 32767.0f;
if(v > 32767.0f) {
v = 32767.0f;
}
if(v < -32768.0f) {
v = -32768.0f;
}
output[i] = (int16_t)v;
}
}
+184
View File
@@ -0,0 +1,184 @@
// Minimal harness: load <name>.TFX (TFMX module) + <name>.SAM (samples),
// bundle into a TFHD container (same layout as amiga_exotic_players'
// try_tfmx_bundle), run tfmx.h's player for N seconds, write a WAV file.
//
// Provenance: tfmx.h, paula.h and player_api.h in this directory are
// vendored, unmodified, from https://github.com/vtlmks/amiga_exotic_players
// (MIT License, itself a C99 port of the TFMX replayer from NostalgicPlayer,
// https://github.com/neumatho/NostalgicPlayer, also MIT). See LICENSE in
// this directory for both notices. This file (render_tfmx.c) and build.sh
// are original code written for the Kellogg's Remake project. Kellogg's
// Tony & Friends stores TFMX music as separate <NAME>.TFX (module/"mdat")
// and <NAME>.SAM (samples/"smpl") files inside PCKELL.DAT; this harness
// re-bundles that pair into the TFHD container tfmx.h expects, since the
// game's naming convention differs from TFMX's usual mdat.<name>/smpl.<name>
// convention.
//
// SONG-INDEX (added 22.07.2026 abends, Stefans Meldung "das Spiel hat noch
// wesentlich mehr Lieder"): ein einzelnes TFX-Modul kann MEHRERE Songs
// buendeln (tfmx.h's v_songs[]/v_songs_count, ausgewaehlt ueber
// player_info.admin.start_song). Bisher haben wir immer nur Song 0
// gerendert. Stichprobe ergab: TITEL.TFX hat 3 Songs, TITEL2.TFX hat 2,
// ONGAME2.TFX sogar 14 (vermutlich je ein Level-Track). Der optionale 5.
// CLI-Arg waehlt den gewuenschten Song; ohne Angabe bleibt das Verhalten
// exakt wie vorher (Song 0, kein tfmx_restart-Aufruf noetig weil das schon
// der Init-Default ist).
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include "player_api.h"
#include "tfmx.h"
static uint8_t *load_file(const char *path, uint32_t *out_len) {
FILE *f = fopen(path, "rb");
if (!f) { perror(path); return 0; }
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *buf = malloc(len);
fread(buf, 1, len, f);
fclose(f);
*out_len = (uint32_t)len;
return buf;
}
static void write_wav(const char *path, int16_t *pcm, int32_t frames, int32_t sample_rate) {
FILE *f = fopen(path, "wb");
uint32_t data_bytes = frames * 2 * sizeof(int16_t);
uint32_t riff_size = 36 + data_bytes;
fwrite("RIFF", 1, 4, f);
fwrite(&riff_size, 4, 1, f);
fwrite("WAVE", 1, 4, f);
fwrite("fmt ", 1, 4, f);
uint32_t fmt_size = 16;
fwrite(&fmt_size, 4, 1, f);
uint16_t audio_format = 1, num_channels = 2;
fwrite(&audio_format, 2, 1, f);
fwrite(&num_channels, 2, 1, f);
fwrite(&sample_rate, 4, 1, f);
uint32_t byte_rate = sample_rate * 2 * sizeof(int16_t);
fwrite(&byte_rate, 4, 1, f);
uint16_t block_align = 2 * sizeof(int16_t);
fwrite(&block_align, 2, 1, f);
uint16_t bits_per_sample = 16;
fwrite(&bits_per_sample, 2, 1, f);
fwrite("data", 1, 4, f);
fwrite(&data_bytes, 4, 1, f);
fwrite(pcm, 1, data_bytes, f);
fclose(f);
}
int main(int argc, char **argv) {
if (argc < 4) {
fprintf(stderr, "usage: %s <mdat.tfx> <smpl.sam> <out.wav> [max_seconds] [song_index]\n", argv[0]);
return 1;
}
// WICHTIG (Fix 22.07.2026 abends): [max_seconds] ist NUR noch eine
// Sicherheits-OBERGRENZE, keine Ziel-Laenge mehr! Vorher wurde exakt
// `seconds` lang gerendert -- war der Wert kleiner als die echte
// Songlaenge, wurde mitten im Stueck abgeschnitten (Stefans Meldung:
// "die Musik ist zu kurz, da fehlt der Rest", verursacht durch TITEL mit
// frueher genutzten 60s bzw. einen stehengebliebenen 8s-Cache-Rest).
// Stattdessen rendern wir jetzt in kleinen Haeppchen und beobachten
// tfmx.h's eigenes `real_song_end`-Flag -- sobald es feuert, haben wir
// GENAU eine vollstaendige natuerliche Song-Schleife im Kasten und
// brechen ab, egal wie lang das Stueck wirklich ist. max_seconds greift
// nur als Notbremse, falls ein Modul (defekt/kaputt) nie ein Ende meldet.
double max_seconds = argc > 4 ? atof(argv[4]) : 180.0;
int song_index = argc > 5 ? atoi(argv[5]) : -1; // -1 = Default-Song (0)
uint32_t mdat_len = 0, smpl_len = 0;
uint8_t *mdat = load_file(argv[1], &mdat_len);
uint8_t *smpl = load_file(argv[2], &smpl_len);
if (!mdat || !smpl) return 1;
uint32_t hdr_off = 18;
uint32_t bundle_len = hdr_off + mdat_len + smpl_len;
uint8_t *bundle = malloc(bundle_len);
memset(bundle, 0, hdr_off);
bundle[0] = 'T'; bundle[1] = 'F'; bundle[2] = 'H'; bundle[3] = 'D';
bundle[4] = 0; bundle[5] = 0;
bundle[6] = (uint8_t)(hdr_off >> 8); bundle[7] = (uint8_t)hdr_off;
bundle[8] = 0; bundle[9] = 0;
bundle[10] = (uint8_t)(mdat_len >> 24); bundle[11] = (uint8_t)(mdat_len >> 16);
bundle[12] = (uint8_t)(mdat_len >> 8); bundle[13] = (uint8_t)mdat_len;
bundle[14] = (uint8_t)(smpl_len >> 24); bundle[15] = (uint8_t)(smpl_len >> 16);
bundle[16] = (uint8_t)(smpl_len >> 8); bundle[17] = (uint8_t)smpl_len;
memcpy(bundle + hdr_off, mdat, mdat_len);
memcpy(bundle + hdr_off + mdat_len, smpl, smpl_len);
int32_t sample_rate = 44100;
// Direkt tfmx_init/tfmx_get_audio statt der generischen player_api-
// Indirektion nutzen -- wir brauchen Zugriff auf state->real_song_end,
// das die generische Schnittstelle (nur get_audio(state, out, frames))
// nicht durchreicht.
struct tfmx_state *s = tfmx_init(bundle, bundle_len, sample_rate);
if (!s) {
fprintf(stderr, "tfmx_init failed (module not recognized)\n");
return 2;
}
if (song_index >= 0) {
if ((uint32_t)song_index >= s->v_songs_count) {
fprintf(stderr, "song_index %d out of range (v_songs_count=%u)\n", song_index, s->v_songs_count);
return 4;
}
// tfmx_init hat bereits Song 0 aufgesetzt (tfmx_init_decoder ->
// tfmx_restart, siehe tfmx.h). Fuer einen anderen Index muessen wir
// den Sequencer neu auf den gewuenschten Song ausrichten:
// start_song setzen, dann tfmx_restart() erneut aufrufen (liest
// v_songs[start_song] und initialisiert Sequencer-Position/Speed neu).
s->player_info.admin.start_song = song_index;
tfmx_restart(s);
}
int32_t max_frames = (int32_t)(max_seconds * sample_rate);
// Chunk klein genug waehlen, dass real_song_end nicht durch einen
// zweiten Tick INNERHALB desselben Chunks schon wieder auf 0 zurueck-
// gesetzt wurde, bevor wir nachsehen (ein Tick liegt typischerweise bei
// ~20ms/882 Frames bei 44.1kHz -- 64 Frames sind davon weit entfernt).
const int32_t CHUNK = 64;
float *scratch = malloc(sizeof(float) * CHUNK * 2);
int16_t *pcm = malloc(sizeof(int16_t) * (size_t)max_frames * 2);
if (!scratch || !pcm) {
fprintf(stderr, "out of memory\n");
return 3;
}
int32_t total = 0;
int natural_end = 0;
while (total < max_frames) {
int32_t this_chunk = CHUNK;
if (total + this_chunk > max_frames) {
this_chunk = max_frames - total;
}
memset(scratch, 0, sizeof(float) * (size_t)this_chunk * 2);
tfmx_get_audio(s, scratch, this_chunk);
int16_t *out_ptr = pcm + (size_t)total * 2;
for (int32_t i = 0; i < this_chunk * 2; ++i) {
float v = scratch[i] * 32767.0f;
if (v > 32767.0f) v = 32767.0f;
if (v < -32768.0f) v = -32768.0f;
out_ptr[i] = (int16_t)v;
}
total += this_chunk;
if (s->real_song_end) {
natural_end = 1;
break;
}
}
write_wav(argv[3], pcm, total, sample_rate);
fprintf(stderr, "wrote %s: song=%d %d frames @ %d Hz (%.2fs)%s\n",
argv[3], song_index, total, sample_rate, (double)total / sample_rate,
natural_end ? " [natuerliches Songende/Loop-Punkt erkannt]"
: " [Sicherheits-Obergrenze erreicht, kein Songende gefunden -- Modul pruefen]");
tfmx_free(s);
return 0;
}
File diff suppressed because it is too large Load Diff