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:
co-authored by
Claude Opus 4.8
parent
3fe92d01b8
commit
85404c91f4
@@ -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]
|
||||
"""
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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.
|
||||
Executable
+10
@@ -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"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user