Завершить разделение SDK и внешних проектов

This commit is contained in:
Александр Петров
2026-09-16 10:01:43 +03:00
parent 0e74aaa7ee
commit 2e7ffd64a4
1965 changed files with 373 additions and 201393 deletions
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# Применяет воспроизводимый backend sdbg к checkout MAME 0.287.
set -eu
project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
mame_source=${1:-"$project_dir/mame/sources/MAME"}
patch_file="$project_dir/toolchain/mame-patches/0001-sdbg-debugger-backend.patch"
if git -C "$mame_source" apply --reverse --check "$patch_file" 2>/dev/null; then
echo "MAME sdbg backend уже применён: $mame_source"
elif git -C "$mame_source" apply --check "$patch_file"; then
git -C "$mame_source" apply "$patch_file"
echo "MAME sdbg backend применён: $mame_source"
else
echo "Не удалось применить sdbg patch к $mame_source" >&2
echo "Ожидается MAME 0.287, commit b0c4527c." >&2
exit 1
fi
+1 -1
View File
@@ -11,7 +11,7 @@
# Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено).
#
# Использование:
# toolchain/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ...
# toolchain/make_hdd.sh build/hdd/test.chd prog.exe data1 data2 ...
# toolchain/make_hdd.sh --dest games/sprpop out.chd prog.exe data1 ...
# toolchain/make_hdd.sh out.chd prog.exe KID:kid0.atl GUARD:g0.atl ...
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог относительно
+6 -5
View File
@@ -13,9 +13,10 @@
# - runtime/ : crt0 + bank/heap support
# - libc/include/ : public headers
# - libc/{io,stdio,mem,gfx}/ : libc sources (assembled into sprinter.lib)
# - lib/Makefile + lib/sprinter.lib : prebuilt library + recipe to rebuild
# - lib/*.lib : prebuilt libc/BGI variants
# - app.mk + disk/run tools : standalone application integration
# - libbgi/ + lib/bgi256*.lib : graphics library
# - tests/ + testkit/ : SDK regression programs
# - third_party/sdcc/ : vendored SDCC 4.5
# - release_docs/ → docs/ : user-facing documentation
# - root Makefile, README, RELEASE_NOTES, LICENSE
@@ -54,7 +55,7 @@ mkdir -p "$STAGE"
cp README.md RELEASE_NOTES.md LICENSE Makefile app.mk "$STAGE/"
# Toolchain
cp -R bin runtime libc libbgi "$STAGE/"
cp -R bin runtime libc libbgi tests testkit "$STAGE/"
# Core tools and source debugger backend needed by sprinter-cc/app.mk.
mkdir -p "$STAGE/toolchain/mkexe"
cp toolchain/mkexe/mkexe "$STAGE/toolchain/mkexe/"
@@ -72,9 +73,8 @@ cp -R toolchain/mcp "$STAGE/toolchain/"
# Lib — keep Makefile and the prebuilt archive only; sources are in libc/
mkdir -p "$STAGE/lib"
cp lib/Makefile "$STAGE/lib/"
cp lib/sprinter.lib "$STAGE/lib/"
cp lib/sprinter_safe.lib lib/bgi256.lib lib/bgi256_safe.lib "$STAGE/lib/"
cp lib/sprinter.lib lib/sprinter_safe.lib \
lib/bgi256.lib lib/bgi256_safe.lib "$STAGE/lib/"
# Vendored SDCC — follow symlinks so the real content lands in the tarball
mkdir -p "$STAGE/third_party"
@@ -90,6 +90,7 @@ mkdir -p "$STAGE/third_party"
echo ">> stripping build artefacts and macOS metadata"
find "$STAGE" \( \
-name '.sprinter-cc-*' -o \
-name '.resource-stamps' -o -name build -o \
-name '*.rel' -o -name '*.lst' -o -name '*.sym' -o -name '*.asm' -o \
-name '*.ihx' -o -name '*.lk' -o -name '*.map' -o -name '*.noi' -o \
-name '*.exe' -o \
@@ -1,129 +0,0 @@
diff --git a/scripts/src/osd/modules.lua b/scripts/src/osd/modules.lua
--- a/scripts/src/osd/modules.lua
+++ b/scripts/src/osd/modules.lua
@@ -69,6 +69,7 @@
MAME_DIR .. "src/osd/modules/debugger/debugkeyconfig.h",
MAME_DIR .. "src/osd/modules/debugger/debuggdbstub.cpp",
MAME_DIR .. "src/osd/modules/debugger/debugimgui.cpp",
+ MAME_DIR .. "src/osd/modules/debugger/sdbg.cpp",
MAME_DIR .. "src/osd/modules/debugger/debugwin.cpp",
MAME_DIR .. "src/osd/modules/debugger/none.cpp",
MAME_DIR .. "src/osd/modules/debugger/xmlconfig.cpp",
diff --git a/scripts/src/osd/mac.lua b/scripts/src/osd/mac.lua
--- a/scripts/src/osd/mac.lua
+++ b/scripts/src/osd/mac.lua
@@ -78,6 +78,7 @@
files {
MAME_DIR .. "src/osd/modules/debugger/debugosx.mm",
+ MAME_DIR .. "src/osd/modules/debugger/sdbgmac.mm",
MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.mm",
MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.h",
MAME_DIR .. "src/osd/modules/debugger/osx/consoleview.mm",
diff --git a/src/osd/modules/debugger/sdbg.cpp b/src/osd/modules/debugger/sdbg.cpp
new file mode 100644
--- /dev/null
+++ b/src/osd/modules/debugger/sdbg.cpp
@@ -0,0 +1,72 @@
+// license:BSD-3-Clause
+// copyright-holders:Sprinter C-Compiler contributors
+//============================================================
+//
+// sdbg.cpp - headless debugger wait loop for an external DAP bridge
+//
+//============================================================
+
+#include "emu.h"
+#include "debug_module.h"
+#include "modules/lib/osdobj_common.h"
+
+#if defined(OSD_MAC)
+void sdbg_poll_mac_events();
+#endif
+
+
+namespace osd {
+
+namespace {
+
+class debug_sdbg : public osd_module, public debug_module
+{
+public:
+ debug_sdbg() : osd_module(OSD_DEBUG_PROVIDER, "sdbg"), debug_module(),
+ m_osd(nullptr), m_next_host_poll(0)
+ { }
+
+ virtual int init(osd_interface &osd, const osd_options &options) override
+ {
+ m_osd = &dynamic_cast<osd_common_t &>(osd);
+ return 0;
+ }
+ virtual void exit() override { }
+
+ virtual void init_debugger(running_machine &machine) override { }
+ virtual void wait_for_debugger(device_t &device, bool firststop) override;
+ virtual void debugger_update() override { }
+
+private:
+ osd_common_t *m_osd;
+ osd_ticks_t m_next_host_poll;
+};
+
+void debug_sdbg::wait_for_debugger(device_t &device, bool firststop)
+{
+ // debugger_cpu::wait_for_debugger вызывает Lua periodic перед каждым
+ // обращением сюда. Короткий sleep исключает busy-loop, после возврата
+ // внешний bridge получает следующую возможность обработать RPC.
+ // Без обработки событий окон macOS считает MAME зависшим, пока CPU
+ // удерживается на точке VS Code. 100 Hz достаточно для Dock/Cmd-Tab.
+ osd_ticks_t const current = osd_ticks();
+ if (current >= m_next_host_poll)
+ {
+#if defined(OSD_MAC)
+ sdbg_poll_mac_events();
+#else
+ // Текущий mame.arm собран на SDL3: pump + poll необходимы,
+ // поскольку обычный frame_update не выполняется при остановке CPU.
+ m_osd->input_update(false);
+ m_osd->process_events();
+#endif
+ m_next_host_poll = current + osd_ticks_per_second() / 100;
+ }
+ osd_sleep(osd_ticks_per_second() / 1000);
+}
+
+} // anonymous namespace
+
+} // namespace osd
+
+MODULE_DEFINITION(DEBUG_SDBG, osd::debug_sdbg)
diff --git a/src/osd/modules/debugger/sdbgmac.mm b/src/osd/modules/debugger/sdbgmac.mm
new file mode 100644
--- /dev/null
+++ b/src/osd/modules/debugger/sdbgmac.mm
@@ -0,0 +1,14 @@
+// license:BSD-3-Clause
+// copyright-holders:Sprinter C-Compiler contributors
+// Прокачка событий Cocoa в headless debugger без окна штатного debugger.
+
+#import <Cocoa/Cocoa.h>
+
+extern void MacPollInputs();
+
+void sdbg_poll_mac_events()
+{
+ NSAutoreleasePool *const pool = [[NSAutoreleasePool alloc] init];
+ MacPollInputs();
+ [pool release];
+}
diff --git a/src/osd/modules/lib/osdobj_common.cpp b/src/osd/modules/lib/osdobj_common.cpp
--- a/src/osd/modules/lib/osdobj_common.cpp
+++ b/src/osd/modules/lib/osdobj_common.cpp
@@ -282,6 +282,7 @@
REGISTER_MODULE(m_mod_man, DEBUG_QT);
REGISTER_MODULE(m_mod_man, DEBUG_IMGUI);
REGISTER_MODULE(m_mod_man, DEBUG_GDBSTUB);
+ REGISTER_MODULE(m_mod_man, DEBUG_SDBG);
REGISTER_MODULE(m_mod_man, DEBUG_NONE);
#endif
-2
View File
@@ -221,8 +221,6 @@ def main():
ap.add_argument("--snapshot-dir", help="каталог кадров, по умолчанию build/mame-autotest")
args = ap.parse_args()
profile = from_arguments(args)
if profile.binary is None and (Path(PROJECT_ROOT)/"mame/v306/mame.arm").is_file():
profile = MameProfile.resolve({"MAME_HOME":str(Path(PROJECT_ROOT)/"mame/v306")})
try:
profile.validate(dss=True)
except ValueError as error:
+1 -1
View File
@@ -27,7 +27,7 @@ png_strip.py — генерик-конвертер произвольного н
следующего вызова. Так несколько независимо
собранных лент (напр. разные типы тайлов) можно
свести в ОДНУ палитру для одновременного показа
на экране (см. applications/PoP/poc/tools/
на экране (см. ../Applications/PoP-Archive/poc/tools/
build_room_palette.py).
Каждая картинка — источник ОДНОГО кадра ленты (не атлас-сетка;
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""pop_extract_font.py — SDLPoP-шрифты в один Sprinter .atl-ресурс.
Источник истины — оригинальные встроенные шрифты SDLPoP:
* hc_small_font_data[] из menu.c — пункты меню;
* hc_font_data[] из seg009.c — крупные сообщения/заголовки.
В исходнике глиф — IMAGE_DATA(height, width, flags) и 1-битные строки.
Здесь его силуэт переводится в getimage (0xFF = прозрачность) и пакуется в
FONT\\FONT.ATL. Это именно шрифтовой атлас, а не заранее нарисованные
строки: номер ленты = код ASCII, поэтому runtime сохраняет произвольный
текст и пропорциональную ширину оригинала.
Хвост страницы [0x3C00..0x3FFF] намеренно оставлен свободным: pop_menu.c
кладёт туда снимок игровой палитры на время паузы. Тогда 1 КБ не занимает
дефицитную W2.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC_SMALL = ROOT / "applications" / "PoP" / "SDLPoP" / "src" / "menu.c"
SRC_BIG = ROOT / "applications" / "PoP" / "SDLPoP" / "src" / "seg009.c"
OUT_DIR = ROOT / "applications" / "PoP" / "poc" / "res" / "font"
OUT_ATL = OUT_DIR / "font.atl"
OUT_H = ROOT / "applications" / "PoP" / "roomtest" / "pop_font.h"
FIRST, LAST = 32, 126
FONT_COLOR = 0x3F
# ВТОРОЙ ЦВЕТ КРУПНОГО ШРИФТА. У SDLPoP шрифт — маска, и цвет задаётся
# параметром рисования (show_hof_text рисует текст дважды: тень цветом 0 со
# сдвигом (1,1) и сам текст цветом 0xB7). У нас цвет запечён в пиксели
# глифа, поэтому второй цвет — это второй набор глифов. Он нужен таблице
# рекордов: чёрная тень строк и тёмное имя на золотой полосе ввода.
# Набор крупный, потому что draw_text() рисует hc_font (seg009:1594).
FONT_DARK_COLOR = 0x3E
# Каталог SPA1 хранит число картинок одним байтом, а три полных набора по
# 95 глифов — это 285. Тёмному набору полный ASCII и не нужен: им пишется
# только таблица рекордов (имена A..Z, время вида 59:43 и курсор ввода
# '_'), поэтому он обрезан по '_'. 190 + 64 = 254 картинки — потолок.
DARK_FIRST, DARK_LAST = 32, 95
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
SNAPSHOT_OFF = 0x3C00
PAGE_SIZE = 16384
def extract_array(path: Path, name: str) -> list[int]:
text = path.read_text()
match = re.search(rf"byte {name}\[\] = \{{(.*?)\n\}};", text, re.S)
assert match, f"{name} not found in {path}"
body = re.sub(r"//[^\n]*", "", match.group(1))
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
def word(mm: re.Match) -> str:
value = int(mm.group(1), 0)
return f"{value & 0xFF}, {(value >> 8) & 0xFF}"
def bin4(mm: re.Match) -> str:
b7, b6, b5, b4 = (int(v, 0) if v != "_" else 0 for v in mm.groups())
return str((b4 << 4) | (b5 << 5) | (b6 << 6) | (b7 << 7))
def bin8(mm: re.Match) -> str:
bits = [int(v, 0) if v != "_" else 0 for v in mm.groups()]
return str(sum(bit << (7 - i) for i, bit in enumerate(bits)))
def image_data(mm: re.Match) -> str:
h, w, flags = (int(v, 0) for v in mm.groups())
return (f"{h & 0xFF}, {(h >> 8) & 0xFF}, "
f"{w & 0xFF}, {(w >> 8) & 0xFF}, "
f"{flags & 0xFF}, {(flags >> 8) & 0xFF}")
body = re.sub(r"\bWORD\s*\(\s*(\w+)\s*\)", word, body)
body = re.sub(r"\bIMAGE_DATA\s*\(\s*(\w+)\s*,\s*(\w+)\s*,\s*(\w+)\s*\)",
image_data, body)
body = re.sub(r"\bBINARY_4\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin4, body)
body = re.sub(r"\bBINARY_8\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin8, body)
body = re.sub(r"\b_\b", "0", body)
# Hex обязан идти первым: иначе regexp съедает в `0x20` только `0`.
return [int(v, 0) for v in re.findall(r"0[xX][0-9a-fA-F]+|\d+", body)]
def parse_glyphs(vals: list[int]) -> tuple[dict, list[dict]]:
first, last = vals[0], vals[1]
font = {
"first": first,
"last": last,
"ascent": vals[2] | (vals[3] << 8),
"below": vals[4] | (vals[5] << 8),
"space_lines": vals[6] | (vals[7] << 8),
"space_chars": vals[8] | (vals[9] << 8),
}
pos = 10 + (last - first + 1) * 2 # offsets у SDLPoP вычисляются в runtime
glyphs = []
while pos < len(vals):
h = vals[pos] | (vals[pos + 1] << 8)
w = vals[pos + 2] | (vals[pos + 3] << 8)
pos += 6 # height, width, flags
stride = (w + 7) // 8
rows = []
for _ in range(h):
rows.append(vals[pos:pos + stride])
pos += stride
glyphs.append({"h": h, "w": w, "rows": rows})
assert len(glyphs) == last - first + 1, f"glyph count {len(glyphs)}"
return font, glyphs
def glyph_pixels(glyph: dict, color: int) -> bytes:
"""1 бит/пиксель SDLPoP -> 8bpp getimage: 1 = color."""
out = bytearray()
for row in glyph["rows"]:
for x in range(glyph["w"]):
byte = row[x // 8]
on = byte & (1 << (7 - (x & 7)))
out.append(color if on else 0xFF)
return bytes(out)
def pack_atlas(fonts: list[tuple[dict, list[dict], int]]) -> int:
entries = []
for font, glyphs, color, first, last in fonts:
assert font["first"] <= first <= last <= font["last"]
entries.extend((glyph, color) for glyph in
glyphs[first - font["first"]:last - font["first"] + 1])
assert len(entries) <= 255
data_off = ATL_DIR_OFF + len(entries) * 8
out = bytearray(data_off)
out[0:4] = ATL_MAGIC
out[4] = len(entries)
for index, (glyph, color) in enumerate(entries):
offset = len(out)
pixels = glyph_pixels(glyph, color)
out += glyph["w"].to_bytes(2, "little")
out += glyph["h"].to_bytes(2, "little")
out += pixels
directory = ATL_DIR_OFF + index * 8
out[directory:directory + 2] = offset.to_bytes(2, "little")
out[directory + 2] = glyph["w"]
out[directory + 3] = glyph["h"]
out[directory + 4] = 1
out[directory + 5] = 1
if len(out) > SNAPSHOT_OFF:
raise SystemExit(f"font atlas: {len(out)} Б, нет места для palette snapshot @ {SNAPSHOT_OFF:#x}")
OUT_DIR.mkdir(parents=True, exist_ok=True)
OUT_ATL.write_bytes(out)
return len(out)
def emit_header(small: dict, big: dict, atlas_size: int) -> None:
small_n = LAST - FIRST + 1
text = f"""/*
* pop_font.h — контракт FONT\\FONT.ATL, СГЕНЕРИРОВАН.
* Источник: SDLPoP hc_small_font_data[] и hc_font_data[].
* Генератор: toolchain/pop_extract_font.py. Руками не править.
*/
#ifndef POP_FONT_H
#define POP_FONT_H
#include <stdint.h>
#define POP_FONT_COLOR 0x{FONT_COLOR:02X}
#define POP_FONT_DARK_COLOR 0x{FONT_DARK_COLOR:02X}
#define POP_FONT_FIRST {FIRST}
#define POP_FONT_LAST {LAST}
#define POP_FONT_SMALL_BASE 0
#define POP_FONT_SMALL_ASCENT {small['ascent']}
#define POP_FONT_SMALL_SPACE {small['space_chars']}
#define POP_FONT_BIG_BASE {small_n}
#define POP_FONT_BIG_ASCENT {big['ascent']}
#define POP_FONT_BIG_SPACE {big['space_chars']}
/* Тот же КРУПНЫЙ шрифт цветом POP_FONT_DARK_COLOR: тень и текст на светлом.
* Набор обрезан по '_' — им пишется только таблица рекордов. */
#define POP_FONT_DARK_BASE {2 * small_n}
#define POP_FONT_DARK_FIRST {DARK_FIRST}
#define POP_FONT_DARK_LAST {DARK_LAST}
#define POP_FONT_SNAPSHOT_OFF 0x{SNAPSHOT_OFF:04X}
#define POP_FONT_ATLAS_SIZE {atlas_size}
#endif
"""
OUT_H.write_text(text)
def main() -> None:
parsed = []
for path, name in ((SRC_SMALL, "hc_small_font_data"), (SRC_BIG, "hc_font_data")):
font, glyphs = parse_glyphs(extract_array(path, name))
print(f"{name}: chars={font['first']}..{font['last']} ascent={font['ascent']} "
f"space={font['space_chars']}")
parsed.append((font, glyphs))
sets = [(parsed[0][0], parsed[0][1], FONT_COLOR, FIRST, LAST),
(parsed[1][0], parsed[1][1], FONT_COLOR, FIRST, LAST),
(parsed[1][0], parsed[1][1], FONT_DARK_COLOR, DARK_FIRST, DARK_LAST)]
atlas_size = pack_atlas(sets)
emit_header(parsed[0][0], parsed[1][0], atlas_size)
print(f"{OUT_ATL}: 2 x {LAST - FIRST + 1} + {DARK_LAST - DARK_FIRST + 1} glyphs, "
f"{atlas_size} Б; "
f"snapshot from 0x{SNAPSHOT_OFF:04X}")
if __name__ == "__main__":
main()
-349
View File
@@ -1,349 +0,0 @@
#!/usr/bin/env python3
"""Упаковка story/PV intro SDLPoP в полосы SPA1 для Sprinter.
Исходники остаются единственным источником истины: TITLE/res41..45, res51 и
PV/res800/850/950/980. Runtime получает только готовые 320x200 композиции
и 256-цветные палитры. Это намеренно переносит смешивание палитров Princess,
Jaffar, спальни и кровати из ограниченного Z80 в build-time.
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
TITLE = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "TITLE"
PV = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "PV"
PRINCE = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "PRINCE"
KID = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "KID"
OUT = ROOT / "applications" / "PoP" / "poc" / "res" / "pv"
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
ATL_MIN_SIZE = 0x100
PAGE_SIZE = 16384
STRIP_H = 49
# ФОН ТЕКСТОВОЙ РАМКИ — ОТДЕЛЬНЫЙ ИНДЕКС. У оригинала это индекс 14 в
# строке палитры спрайтов title40, и load_title_images() красит его либо в
# #100060 (интро), либо в #800000 (финал, seg001:586). Раньше мы
# ремапили 14 в 9 — «тёмно-синий» палитры res51; подменить его нельзя,
# потому что тем же индексом нарисована сама титульная картинка (8265
# пикселей). Поэтому фон уезжает в свободный индекс, и финал красит
# только его (pop_pal_story_ending).
STORY_BG_INDEX = 16
STORY_BG_COLOR = (0x10, 0x00, 0x60)
# Цвет текста таблицы рекордов: 0xB7 оригинала = седьмой цвет палитры
# title (224,160,0). Глифы шрифта запечены индексом POP_FONT_COLOR, так
# что цвет им даёт именно эта запись; тёмный набор (POP_FONT_DARK_COLOR)
# остаётся чёрным — старшие записи story.pal и так чёрные.
STORY_FONT_INDEX = 0x3F
def transpose_cols(width: int, height: int, rowmajor: bytes) -> bytes:
"""Row-major -> column-major для бесплатного horizontal flip в BGI."""
out = bytearray(width * height)
for column in range(width):
base = column * height
for row in range(height):
out[base + row] = rowmajor[row * width + column]
return bytes(out)
def atlas_blob(pixels: bytes, width: int, height: int) -> bytes:
"""Один непрозрачный getimage в минимальном SPA1-атласе."""
data_off = ATL_DIR_OFF + 8
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = 1
out[ATL_DIR_OFF:ATL_DIR_OFF + 2] = data_off.to_bytes(2, "little")
out[ATL_DIR_OFF + 2] = min(width, 255)
out[ATL_DIR_OFF + 3] = height
out[ATL_DIR_OFF + 4] = out[ATL_DIR_OFF + 5] = 1
out += width.to_bytes(2, "little") + height.to_bytes(2, "little")
out += pixels
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"intro atlas {len(out)} B > {PAGE_SIZE} B")
return bytes(out)
def sprite_atlas(path: Path, entries: dict[int, tuple[int, int, bytes]]) -> None:
"""Атлас небольших прозрачных кадров, целиком живущий в одной EMM.
В отличие от atlas_blob() для полноэкранной полосы, здесь нужен каталог
нескольких картинок: runtime держит страницу открытой всю PV-сцену и
переключает только idx. Пустые индексы не требуются — все наборы ниже
нумеруются подряд с нуля.
"""
count = len(entries)
if not count or count > 255:
raise SystemExit(f"{path}: bad sprite atlas count {count}")
data_off = ATL_DIR_OFF + count * 8
directory = bytearray(count * 8)
blobs = bytearray()
for idx in range(count):
width, height, pixels = entries[idx]
offset = data_off + len(blobs)
blobs += width.to_bytes(2, "little") + height.to_bytes(2, "little")
blobs += pixels
d = idx * 8
directory[d:d + 2] = offset.to_bytes(2, "little")
directory[d + 2] = width
directory[d + 3] = height
directory[d + 4] = directory[d + 5] = 1
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = count
out[ATL_DIR_OFF:ATL_DIR_OFF + len(directory)] = directory
out += blobs
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"{path}: {len(out)} B > one EMM page")
path.write_bytes(out)
def sprite_frames(directory: Path, first: int, last: int,
color_base: int, column_major: bool = False
) -> dict[int, tuple[int, int, bytes]]:
"""PNG-кадры -> getimage. Ноль PNG становится прозрачным 0xFF BGI."""
result = {}
for index, number in enumerate(range(first, last + 1)):
image = png(directory, number)
pixels = bytes(0xFF if pixel == 0 else color_base + pixel
for pixel in image.tobytes())
if column_major:
pixels = transpose_cols(image.width, image.height, pixels)
result[index] = image.width, image.height, pixels
return result
def png(directory: Path, number: int) -> Image.Image:
image = Image.open(directory / f"res{number}.png")
if image.mode != "P":
raise SystemExit(f"res{number}.png: expected indexed PNG, got {image.mode}")
return image.copy()
def pal_file(path: Path) -> list[tuple[int, int, int]]:
"""16 цветов из .pal ресурса (4 байта заголовка, дальше RGB по 6 бит)."""
raw = path.read_bytes()
return [(raw[4 + i * 3] << 2, raw[5 + i * 3] << 2, raw[6 + i * 3] << 2)
for i in range(16)]
def palette(image: Image.Image) -> list[tuple[int, int, int]]:
raw = image.getpalette()
return [tuple(raw[index * 3:index * 3 + 3]) for index in range(16)]
def write_palette(path: Path, entries: list[tuple[int, int, int]],
extra=None) -> None:
"""B,G,R,0 — ровно формат gfx_pal_fload; незадействованное чёрное."""
out = bytearray()
entries = (entries + [(0, 0, 0)] * 256)[:256]
for index, color in (extra or {}).items():
entries[index] = color
for red, green, blue in entries:
out += bytes((blue, green, red, 0))
path.write_bytes(out)
def write_scene(prefix: str, number: int, canvas: Image.Image) -> None:
if canvas.size != (320, 200):
raise SystemExit(f"{prefix}{number}: expected 320x200, got {canvas.size}")
for part, top in enumerate(range(0, 200, STRIP_H)):
band = canvas.crop((0, top, 320, min(top + STRIP_H, 200)))
(OUT / f"{prefix}{number}_{part}.atl").write_bytes(
atlas_blob(band.tobytes(), band.width, band.height))
def story_scenes() -> list[Image.Image]:
"""show_title/end_sequence в единой с title палитре.
res41 кодирует тёмный фон индексом 14, а его палитра делает этот индекс
чёрным; show_title() SDLPoP подменяет его #100060 либо #800000. Тексты
res42..45 имеют индекс 1, но этот индекс у res41 тоже тёмный; ремап на
индекс title/res51 15 (белый) позволяет проявлять историю прямо поверх
последнего title-экрана без смены палитры. Фон уезжает в
STORY_BG_INDEX, чтобы финал мог покрасить его отдельно.
Последний экран — фон таблицы рекордов: та же рамка плюс логотип
PRINCE OF PERSIA на y=24 (HOF_POP оригинала — тот же спрайт res54, что
и в титрах, только выше).
"""
frame_raw = bytearray(png(TITLE, 41).tobytes())
for offset, pixel in enumerate(frame_raw):
if pixel == 14:
frame_raw[offset] = STORY_BG_INDEX
frame = Image.frombytes("P", (320, 200), bytes(frame_raw))
result = []
for text_number, ypos in ((42, 25), (43, 25), (45, 26), (44, 25)):
canvas = frame.copy()
raw = bytearray(canvas.tobytes())
text = png(TITLE, text_number)
for sy in range(text.height):
for sx in range(text.width):
if text.getpixel((sx, sy)):
raw[(ypos + sy) * 320 + 24 + sx] = 15
canvas.frombytes(bytes(raw))
result.append(canvas)
result.append(frame)
hof = frame.copy()
raw = bytearray(hof.tobytes())
logo = png(TITLE, 54)
for sy in range(logo.height):
for sx in range(logo.width):
pixel = logo.getpixel((sx, sy))
if pixel: # blitters_10h_transp: 0 сквозной
raw[(24 + sy) * 320 + 24 + sx] = pixel
hof.frombytes(bytes(raw))
result.append(hof)
return result
def pv_palette_canvas() -> tuple[Image.Image, list[tuple[int, int, int]]]:
"""Спальня + bed. Номера 0..15=room, 16..31=Princess,
32..47=Jaffar, 48..63=bed. Благодаря этому у смешанных PNG не теряются
исходные цвета при одном gfx_pal_fload() на весь PV."""
room = png(PV, 951)
bed = png(PV, 981)
raw = bytearray(room.tobytes())
bed_raw = bed.tobytes()
for sy in range(bed.height):
for sx in range(bed.width):
pixel = bed_raw[sy * bed.width + sx]
if pixel:
raw[(142 + sy) * 320 + sx] = 48 + pixel
canvas = Image.frombytes("P", room.size, bytes(raw))
# 80..95 — пламя факелов. Это отдельный chtab PRINCE в оригинале; без
# отдельной строки цвета динамические кадры становились бы цветами стены.
# 112..127 (0x70) — палитра КИДА: на PV-экране финала и сцен 8/9 он и
# мышь рисуются СВОИМИ атласами (chtab_2), а их индексы запечены под
# игровые слоты 0x70. Без этой строки они выходили бы чёрными.
return (canvas, palette(room) + palette(png(PV, 801)) +
palette(png(PV, 851)) + palette(bed) + palette(png(PV, 901)) +
palette(png(PRINCE, 151)) + [(0, 0, 0)] * 16 +
pal_file(KID / "res400.pal"))
def sprite_paste(dst: Image.Image, sprite: Image.Image, x: int, floor: int,
color_base: int) -> None:
"""Положить спрайт ногами на floor, remap его палитру в отдельную строку."""
raw = bytearray(dst.tobytes())
src = sprite.tobytes()
y = floor - sprite.height
for sy in range(sprite.height):
dy = y + sy
if dy < 0 or dy >= 200:
continue
for sx in range(sprite.width):
dx = x + sx
pixel = src[sy * sprite.width + sx]
if pixel and 0 <= dx < 320:
raw[dy * 320 + dx] = color_base + pixel
dst.frombytes(bytes(raw))
def pv_scenes() -> tuple[list[Image.Image], list[tuple[int, int, int]]]:
base, colors = pv_palette_canvas()
# Позиции Char из init_princess()/init_vizier(): x=120/198, y=166.
# В оригинале точный offset задаёт frame table; статические композиции
# выравнивают персонажей по полу y=166, не хранят таблицы PV-анимации.
waiting = base.copy()
sprite_paste(waiting, png(PV, 801), 110, 166, 16)
jaffar = waiting.copy()
sprite_paste(jaffar, png(PV, 851), 188, 166, 32)
magic = base.copy()
sprite_paste(magic, png(PV, 805), 110, 166, 16)
sprite_paste(magic, png(PV, 870), 188, 166, 32)
# res953 — первый полный кадр песочных часов; он использует палитру room.
sprite_paste(magic, png(PV, 953), 18, 166, 0)
alone = base.copy()
sprite_paste(alone, png(PV, 816), 110, 166, 16)
sprite_paste(alone, png(PV, 959), 18, 166, 0)
# FG9: статические ключевые позы тех же scene 2/4/6/8/9/12. PV2
# использует второй набор Princess (res900), поэтому он лежит в строке
# палитры 64..79. Мышь в оригинале меняет только короткую позу рядом с
# Princess; здесь соответствующая стадия выделена отдельным кадром.
stand = base.copy()
sprite_paste(stand, png(PV, 801), 134, 166, 16)
lying = base.copy()
sprite_paste(lying, png(PV, 901), 78, 162, 64)
# Часы для cutscene_4 выбираются runtime по оставшимся минутам и
# анимируются вместе с песком; в статической основе их быть не должно.
mouse = base.copy()
sprite_paste(mouse, png(PV, 920), 120, 169, 64)
sprite_paste(mouse, png(PV, 959), 18, 166, 0)
short_time = base.copy()
sprite_paste(short_time, png(PV, 816), 146, 166, 16)
sprite_paste(short_time, png(PV, 953), 18, 166, 0)
return [waiting, jaffar, magic, alone, stand, lying, mouse, short_time], colors
def pv_animation_assets() -> None:
"""Ресурсы покадрового intro.
`b0_*` — единственный чистый фон. Он копируется accelerator-ом между
обеими экранными страницами; спрайты рисуются банком без shadow, так что
следующий кадр автоматически начинает с фона. Каждый actor-атлас <=16K,
поэтому нет чтения 320x200 с HDD в анимационном цикле.
"""
base, _ = pv_palette_canvas()
write_scene("b", 0, base)
# frame_tbl_cuts из SDLPoP адресует Princess как image 0..16 ресурса
# 800 (то есть PNG res801..817). Для Jaffar PV1 адресуется ресурс 850:
# image 0..37 = res851..888. Все 38 кадров не помещаются в одну EMM,
# поэтому сохраняем их тремя последовательными атласами. Runtime знает
# границы, но номер кадра остаётся исходным image-id 0..37.
sprite_atlas(OUT / "a0.atl", sprite_frames(PV, 801, 817, 16, True))
sprite_atlas(OUT / "j0.atl", sprite_frames(PV, 851, 863, 32, True))
sprite_atlas(OUT / "j1.atl", sprite_frames(PV, 864, 874, 32, True))
sprite_atlas(OUT / "j2.atl", sprite_frames(PV, 875, 888, 32, True))
# ВТОРОЙ набор Princess (PV2, res901..930 = image 0..29 ресурса 900).
# Он нужен сценам 8/9 (сидит и гладит мышь, встаёт, приседает) и финалу
# (поворачивается и обнимает Кида) — поз PV1 для этого не хватает.
# 26.7 КБ не влезают в одну EMM, поэтому два атласа; номер кадра при
# этом остаётся исходным image-id 0..29, границу знает runtime.
# Палитровая строка 64..79 — та же, что у PV2 в статических сценах.
sprite_atlas(OUT / "a1.atl", sprite_frames(PV, 901, 917, 64, True))
sprite_atlas(OUT / "a2.atl", sprite_frames(PV, 918, 930, 64, True))
# res953/res954 — состояния часов, res960..962 — три фазы песка.
sprite_atlas(OUT / "h0.atl", sprite_frames(PV, 952, 962, 0))
sprite_atlas(OUT / "t0.atl", sprite_frames(PRINCE, 151, 159, 80))
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
stories = story_scenes()
write_palette(OUT / "story.pal", palette(png(TITLE, 51)),
{STORY_BG_INDEX: STORY_BG_COLOR,
STORY_FONT_INDEX: (224, 160, 0)})
for number, scene in enumerate(stories):
write_scene("s", number, scene)
scenes, colors = pv_scenes()
write_palette(OUT / "pv.pal", colors)
for number, scene in enumerate(scenes):
write_scene("p", number, scene)
pv_animation_assets()
print(f"{OUT}: {len(stories)} story + 8 PV compositions + base x 5 strips, "
f"6 animated actor atlases, one strip/page <= {STRIP_H} rows")
if __name__ == "__main__":
main()
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/env python3
"""pop_pack_title.py — титульные композиции SDLPoP для Sprinter.
Исходник — PNG-ресурсы ``data/TITLE/res51..55`` из локального SDLPoP.
Runtime не должен держать 320x200 картинку в W2 и не разбирает PNG: каждая
готовая композиция разрезана на пять getimage-лент высотой не более 49
строк. Лента вместе с заголовком атласа занимает одну EMM-страницу; экран
рисуется лента за лентой, и страница сразу освобождается.
Композиции в точности повторяют ``show_title()`` до story/intro:
t0 — основной рисунок;
t1 — рисунок + PRESENTS;
t2 — рисунок + название игры;
t3 — рисунок + логотип PRINCE OF PERSIA и Jordan Mechner.
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "TITLE"
OUT = ROOT / "applications" / "PoP" / "poc" / "res" / "title"
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
ATL_MIN_SIZE = 0x100
PAGE_SIZE = 16384
STRIP_H = 49
def atlas_blob(pixels: bytes, width: int, height: int) -> bytes:
"""Один непрозрачный getimage в минимальном SPA1-атласе."""
data_off = ATL_DIR_OFF + 8
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = 1
out[ATL_DIR_OFF:ATL_DIR_OFF + 2] = data_off.to_bytes(2, "little")
# Каталог хранит preview-габарит в uint8, но atlas_image читает только
# offset; реальный getimage-width — u16 в блобе. Так уже пакуются
# широкие 320-пиксельные полосы фона.
out[ATL_DIR_OFF + 2] = min(width, 255)
out[ATL_DIR_OFF + 3] = height
out[ATL_DIR_OFF + 4] = out[ATL_DIR_OFF + 5] = 1
out += width.to_bytes(2, "little") + height.to_bytes(2, "little")
out += pixels
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"title atlas {len(out)} B > {PAGE_SIZE} B")
return bytes(out)
def image(n: int) -> Image.Image:
im = Image.open(SRC / f"res{n}.png")
if im.mode != "P":
raise SystemExit(f"res{n}.png: expected indexed PNG, got {im.mode}")
return im.copy()
def assert_palette(base: Image.Image, overlay: Image.Image, n: int) -> None:
if base.getpalette()[:48] != overlay.getpalette()[:48]:
raise SystemExit(f"res{n}.png uses a palette different from res51.png")
def paste_opaque(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
assert_palette(dst, src, n)
dst.paste(src, (x, y))
def paste_transparent(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
assert_palette(dst, src, n)
# TITLE_POP — единственный из пяти элементов, который SDLPoP рисует
# blitters_10h_transp. В PNG тот же факт записан colour-key index 0.
# Image.point() у P-изображения возвращает снова P, а paste() принимает
# маску только L/1/RGBA. Строим L явно, не меняя индексы самой картинки.
mask = Image.frombytes("L", src.size,
bytes(0 if p == 0 else 255 for p in src.tobytes()))
dst.paste(src, (x, y), mask)
# Семнадцатая запись — фон текстовой рамки story (STORY_BG_INDEX в
# pop_pack_intro.py). Она нужна здесь потому, что «In the absence…»
# проявляется полосами ПОВЕРХ последнего кадра титров, то есть при ещё
# загруженной title.pal: без этой записи фон рамки был бы чёрным.
STORY_BG_INDEX = 16
STORY_BG_COLOR = (0x10, 0x00, 0x60)
def write_palette(base: Image.Image) -> None:
rgb = base.getpalette()[:48]
pal = bytearray()
for i in range(16):
r, g, b = rgb[i * 3:i * 3 + 3]
pal += bytes((b, g, r, 0)) # родной формат gfx_pal_fload
assert len(pal) == STORY_BG_INDEX * 4
r, g, b = STORY_BG_COLOR
pal += bytes((b, g, r, 0))
(OUT / "title.pal").write_bytes(pal)
def write_scene(scene: int, canvas: Image.Image) -> int:
count = 0
for part, top in enumerate(range(0, canvas.height, STRIP_H)):
band = canvas.crop((0, top, canvas.width,
min(top + STRIP_H, canvas.height)))
path = OUT / f"t{scene}_{part}.atl"
path.write_bytes(atlas_blob(band.tobytes(), band.width, band.height))
count += 1
return count
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
main_image = image(51)
if main_image.size != (320, 200):
raise SystemExit(f"res51.png: expected 320x200, got {main_image.size}")
presents = main_image.copy()
paste_opaque(presents, image(52), 96, 106, 52)
game = main_image.copy()
paste_opaque(game, image(53), 96, 122, 53)
logo = main_image.copy()
paste_transparent(logo, image(54), 24, 107, 54)
paste_opaque(logo, image(55), 48, 184, 55)
write_palette(main_image)
scenes = (main_image, presents, game, logo)
strips = [write_scene(i, scene) for i, scene in enumerate(scenes)]
assert strips == [5, 5, 5, 5]
print(f"{OUT}: {len(scenes)} title compositions x {strips[0]} strips, "
f"one strip <= {STRIP_H} rows / one EMM page")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
room_compose.py — генерик-компоновщик полноэкранного фона комнаты из
тайлов ПРОИЗВОЛЬНОГО размера (см. applications/PoP/docs/PORT_PLAN.md §4:
тайлов ПРОИЗВОЛЬНОГО размера (см. ../Applications/PoP-Archive/docs/PORT_PLAN.md §4:
один полноэкранный фон на комнату готовится офлайн, поверх — спрайты
через sprite.h). Не завязан ни на какой конкретный проект/игру — просто
берёт раскладку тайлов, которую вы сами опишете, и картинки, которые
+9 -2
View File
@@ -6,7 +6,7 @@ if [ -f "$mame_root/.codex/mame.local.env" ]; then
. "$mame_root/.codex/mame.local.env"
fi
: "${MAME_UV:=uv}"
: "${MAME_MCP_SCRIPT:=mame/sources/MAME/src/mame_mcp.py}"
: "${MAME_MCP_SCRIPT:=../MAME/src/mame_mcp.py}"
cd "$mame_root"
if ! command -v "$MAME_UV" >/dev/null 2>&1; then
echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2
@@ -16,4 +16,11 @@ if [ ! -f "$MAME_MCP_SCRIPT" ]; then
echo "mame-z80: сервер не найден: $MAME_MCP_SCRIPT; задайте MAME_MCP_SCRIPT в .codex/mame.local.env" >&2
exit 1
fi
exec "$MAME_UV" run --python 3.12 --no-project --with 'mcp<2' "$MAME_MCP_SCRIPT"
if [ -z "${MAME_PYTHON:-}" ]; then
if ! command -v pyenv >/dev/null 2>&1; then
echo "mame-z80: pyenv не найден; задайте MAME_PYTHON" >&2
exit 1
fi
MAME_PYTHON=$(pyenv which python)
fi
exec "$MAME_UV" run --python "$MAME_PYTHON" --no-project --with 'mcp<2' "$MAME_MCP_SCRIPT"
+17 -1
View File
@@ -66,6 +66,20 @@ class DebugMap:
logical = address & 0xffff
return asdict(Location(address, logical, section, bank, logical >> 14))
def variable_location(self, address, symbol):
"""Разрешить доказанный linker-символ `__at` вне обычных секций.
Код/точки по-прежнему обязаны принадлежать ровно одной секции. Для
global/static SDCC может намеренно поместить объект по абсолютному
адресу (например, scratch в W1); его адрес подтверждают и CDB, и NOI.
"""
try:
return self.location(address)
except ValueError:
if not (0 <= address <= 0xffff and self.symbols.get(symbol) == address):
raise
return asdict(Location(address, address, '_ABS', None, address >> 14))
def _load(self, records):
addresses, declarations = {}, []
module = None
@@ -128,10 +142,12 @@ class DebugMap:
if address is None and parts[0] == 'G': address = self.symbols.get('_'+parts[1])
if address is None: continue
supported = bool(re.fullmatch(r'S[ICL]:[SU]', ctype) or ctype.startswith('DG,'))
linker_symbol = key if key in self.symbols else '_'+parts[1]
variable = {'name': parts[1], 'module': module if parts[0] != 'G' else None,
'size': int(size), 'type': ctype,
'signed': ctype.endswith(':S') and not ctype.startswith('D'),
'supported': supported, **self.location(address)}
'supported': supported,
**self.variable_location(address, linker_symbol)}
if variable not in self.variables: self.variables.append(variable)
self.functions.sort(key=lambda f: f['start'])
self.markers.sort(key=lambda m: m['link_address'])
+4
View File
@@ -106,6 +106,10 @@ class Protocol:
for key, option in profile_arguments.items():
if arguments.get(key):
command.extend([option, arguments[key]])
if arguments.get('appHdd'):
command.extend(['--app-hdd', arguments['appHdd']])
if arguments.get('launchPath'):
command.extend(['--launch-path', arguments['launchPath']])
if arguments.get('debugger'):
command.extend(['--debugger', arguments['debugger']])
if arguments.get('launchAt') is not None:
+5 -1
View File
@@ -84,8 +84,12 @@ def run(wrapper, args):
locks.mkdir(exist_ok=True)
with (locks / (final_work.name + '.lock')).open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
with tempfile.TemporaryDirectory(prefix='.sdbg-build-', dir=output.parent) as temporary:
keep_stage = bool(os.environ.get('SDBG_KEEP_FAILED_STAGE'))
with tempfile.TemporaryDirectory(prefix='.sdbg-build-', dir=output.parent,
delete=not keep_stage) as temporary:
stage = Path(temporary)
if keep_stage:
print('sdbg: диагностическая stage ' + str(stage), file=sys.stderr)
new_exe = stage / output.name
command = list(args)
command[command.index('-o')+1] = str(new_exe)
+21 -6
View File
@@ -7,6 +7,7 @@ from contextlib import redirect_stdout
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
@@ -157,6 +158,10 @@ def main():
parser.add_argument('--build',required=True)
parser.add_argument('--socket',required=True)
parser.add_argument('--data',action='append',default=[])
parser.add_argument('--app-hdd',default=None,
help='локальный CHD приложения для -hard2')
parser.add_argument('--launch-path',default=None,
help='путь к EXE в DSS, например d:\\games\\sprpop\\sprpop.exe')
parser.add_argument('--launch-at',type=float,default=0,
help='не начинать ввод раньше этой секунды эмуляции')
parser.add_argument('--dss-timeout',type=float,default=30,
@@ -181,8 +186,6 @@ def main():
if args.mame and not args.mame_bin:
args.mame_bin=args.mame
profile=from_arguments(args)
if profile.binary is None and (ROOT/'mame/v306/mame.arm').is_file():
profile=MameProfile.resolve({'MAME_HOME':str(ROOT/'mame/v306')})
profile.validate(dss=True)
mame=profile.binary
stopping=False
@@ -203,7 +206,15 @@ def main():
with redirect_stdout(sys.stderr):
if not create_floppy_image(str(disk_path),files):
raise RuntimeError('Не удалось создать debug-дискету')
command_text='a:\\'+basename_83(exe.name).replace(' ','')+'\n'
if args.launch_path:
launch_path=args.launch_path.replace('/', '\\')
if not re.fullmatch(r'[A-Za-z]:\\[A-Za-z0-9_.\\-]+',launch_path):
raise ValueError('launch-path: ожидается путь DOS вида d:\\dir\\app.exe')
if Path(launch_path.split('\\')[-1]).name.upper() != exe.name.upper():
raise ValueError('launch-path должен заканчиваться именем debug EXE: '+exe.name)
command_text=launch_path+'\n'
else:
command_text='a:\\'+basename_83(exe.name).replace(' ','')+'\n'
events=build_events([(0,command_text)])
ready=state/'main.json'
lua=state/'launch.lua'
@@ -215,9 +226,6 @@ def main():
installed_plugins=profile.home.parent/'plugins' if profile.home else None
if installed_plugins and installed_plugins.is_dir():
plugin_paths.append(str(installed_plugins))
legacy_plugins=ROOT/'mame/sources/MAME/plugins'
if legacy_plugins.is_dir():
plugin_paths.append(str(legacy_plugins))
command=[str(mame),'sprinter','-noreadconfig','-rompath',str(profile.rompath),
'-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k','-video','soft','-window',
'-sound','none','-skip_gameinfo','-beta:wd179x:0','35hd',
@@ -226,6 +234,13 @@ def main():
'-debug','-debugger',args.debugger,
'-plugin','sdbgbridge','-pluginspath',';'.join(plugin_paths),
'-autoboot_delay','0','-autoboot_script',str(lua)]
if args.app_hdd:
app_hdd=Path(args.app_hdd).resolve()
if not app_hdd.is_file():
raise ValueError('Не найден CHD приложения: '+str(app_hdd))
mounted_app_hdd=state/'application.chd'
shutil.copyfile(app_hdd,mounted_app_hdd)
command.extend(['-hard2',str(mounted_app_hdd)])
for name in ('nvram','cfg','diff','snapshot'):
command.extend(['-'+name+'_directory',str(state/name)])
log=(state/'mame.log').open('w')
+2 -2
View File
@@ -13,9 +13,9 @@ docs/size_baseline.tsv.
программы (нет в эталоне) — предупреждение, добавить через --update.
Запускать после `make all`.
ТОЛЬКО tests/, без examples/: регресс ловит разжирение libc, а для этого
ТОЛЬКО tests/, без отдельного репозитория Examples: регресс ловит разжирение libc, а для этого
хватает мелких тестов — каждый тянет свой кусок библиотеки и пересобирается
за секунды. Крупные приложения (examples/mdview компилируется минутами)
за секунды. Крупные приложения (`../Examples/mdview` компилируется минутами)
не показывают ничего сверх этого и лишь удлиняют цикл, поэтому и из
`make all` они убраны (см. корневой Makefile).
"""
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Sprinter C Compiler contributors
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.
-81
View File
@@ -1,81 +0,0 @@
# Sprinter MAME Debug
VS Code-клиент сборки и отладки для `toolchain/sdbg_dap.py`. Режим `launch` сам
поднимает изолированные MAME и session server; перед `attach` их запускают
вручную.
> [!WARNING]
> Native Windows пока не поддерживает полный launch/attach: `windows`
> выбирает только debugger provider MAME, а host-часть всё ещё зависит от
> `fcntl`, Unix domain sockets и Unix launcher.
Для разработки расширение можно открыть отдельным окном VS Code и запустить
Extension Development Host. Конфигурация проекта:
```sh
code --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" "$PWD"
```
Python выбирается без зависимости от `PATH` GUI: при наличии local
`.python-version` используется `~/.pyenv/shims/python`; для внешнего workspace
без неё расширение ищет установленный `~/.pyenv/versions/3.12*/bin/python`.
Версия local передаётся задаче через `PYENV_VERSION`, поэтому сборка работает
и если `project` лежит вне workspace. При необходимости задайте абсолютный путь через
`sprinterDebugger.pythonCommand`. Выбранные пути печатаются в канале Output
`Sprinter MAME Debug`.
```json
{
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello"
}
```
Перед F5 расширение по умолчанию находит Makefile проекта по пути `build` и
выполняет задачу `make SRC_DEBUG=1` в каталоге проекта через local Python 3.12.
Ошибки SDCC с файлом и строкой попадают в Problems. При ненулевом коде
сборки debug launch отменяется до старта MAME. Для нестандартной раскладки
укажите `"project": "${workspaceFolder}/path/to/app"`; для уже собранного
пакета можно задать `"autoBuild": false`. Явный `preLaunchTask` остаётся под
контролем стандартного механизма VS Code и отключает автоматическую задачу
расширения.
Команда палитры `Sprinter: Build Active Project` строит проект открытого
C-файла. В `Tasks: Run Task` доступны задачи `Sprinter: Build ...` для
Makefile, включающих `app.mk`. Сборка не заменяет исходный `make` и не пишет
в общий образ дискеты MAME.
По умолчанию patched backend `sdbg` не открывает отдельное окно debugger MAME.
Чтобы пользоваться им одновременно с VS Code, добавьте в launch:
```json
"debugger": "osx"
```
Без project patch можно задать `auto`; явные native provider: `osx` на macOS,
`windows` в Windows, `qt` или `imgui` в Linux. Host-инструменты sdbg сейчас
рассчитаны на macOS/Linux (`fcntl`, Unix sockets и Unix launcher); native
Windows transport и end-to-end тесты ещё требуются.
Готовность DSS определяется по стабильному prompt в VRAM. `dssTimeout`
задаёт таймаут (по умолчанию 30 секунд), а `launchAt` — необязательное самое
раннее время ввода команды.
Ручной attach:
```json
{
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "/tmp/sprinter-sdbg.sock"
}
```
Сейчас доступны точки по исходнику/функции, `logMessage` с безопасными
подстановками `{variable}`, один проверенный frame, регистры, поддержанные
global/static, continue/pause и instruction step. Source step поддержан для
F10/F11/Shift+F11; банковский step-over и step-out проходят
служебные trampoline до следующей C-позиции.
-53
View File
@@ -1,53 +0,0 @@
const fs = require('fs');
const path = require('path');
function absolutePath(value, workspace) {
if (!value || !workspace) return null;
const expanded = value.replace(/\$\{workspaceFolder\}/g, workspace);
return path.resolve(workspace, expanded);
}
function projectForLaunch(configuration, workspace) {
const explicit = absolutePath(configuration.project, workspace);
if (explicit) {
if (!fs.existsSync(path.join(explicit, 'Makefile'))) {
throw new Error(`Нет Makefile в каталоге проекта: ${explicit}`);
}
return explicit;
}
const build = absolutePath(configuration.build, workspace);
if (!build) return null;
let directory = path.dirname(build);
while (directory !== path.dirname(directory)) {
if (fs.existsSync(path.join(directory, 'Makefile'))) {
return directory;
}
if (directory === workspace) break;
directory = path.dirname(directory);
}
return null;
}
function isSprinterMakefile(filename) {
try {
return /include\s+(?:\$\((?:PROJ_ROOT|SPRINTER_ROOT)\)\/app\.mk|\$\(CURDIR\)\/(?:\.\.\/){0,2}sdk\.mk)/.test(
fs.readFileSync(filename, 'utf8'));
} catch (_) {
return false;
}
}
function taskLabel(project, workspace) {
const relative = path.relative(workspace, project);
return `Sprinter: Build ${relative || '.'}`;
}
function makeCommand() {
if (process.platform === 'win32') {
throw new Error('Native Windows build/debug пока не поддерживается');
}
return fs.existsSync('/usr/bin/make') ? '/usr/bin/make' : 'make';
}
module.exports = {absolutePath, projectForLaunch, isSprinterMakefile,
taskLabel, makeCommand};
@@ -1,40 +0,0 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {projectForLaunch, isSprinterMakefile, taskLabel} =
require('./build');
const manifest = require('./package.json');
const workspace = path.resolve(__dirname, '..', '..');
const hello = path.join(workspace, 'tests', 'hello');
test('launch находит Makefile рядом с debug-пакетом', () => {
assert.equal(projectForLaunch({
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
}, workspace), hello);
assert.equal(taskLabel(hello, workspace), 'Sprinter: Build tests/hello');
assert.equal(isSprinterMakefile(path.join(hello, 'Makefile')), true);
});
test('пакет в build/ находит Makefile приложения уровнем выше', () => {
const project = path.join(workspace, 'applications', 'SprPoP');
assert.equal(projectForLaunch({
build: '${workspaceFolder}/applications/SprPoP/build/.sprinter-cc-sprpop',
}, workspace), project);
});
test('явный project без Makefile даёт ошибку вместо stale запуска', () => {
assert.throws(() => projectForLaunch({
project: 'tests/sdbg/fixtures', build: 'tests/hello/.sprinter-cc-hello',
}, workspace), /Нет Makefile/);
});
test('matcher разрешает относительную ошибку SDCC внутри проекта', () => {
const matcher = manifest.contributes.problemMatchers[0];
const line = 'hello.c:62: error 20: Undefined identifier \'missing_name\'';
const match = new RegExp(matcher.pattern.regexp).exec(line);
assert.deepEqual(match?.slice(1),
['hello.c', '62', 'error', '20',
"Undefined identifier 'missing_name'"]);
assert.equal(matcher.fileLocation, 'relative');
});
@@ -1,199 +0,0 @@
const path = require('path');
const vscode = require('vscode');
const {resolvePython, pyenvEnvironment, resolveSdkRoot} = require('./runtime');
const {projectForLaunch, isSprinterMakefile, taskLabel, makeCommand} =
require('./build');
class SprinterAdapterFactory {
constructor(output) {
this.output = output;
}
createDebugAdapterDescriptor(session) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const folder = session.workspaceFolder || vscode.workspace.workspaceFolders?.[0];
const configured = session.configuration.adapterPath;
const sdk = configured ? null : resolveSdkRoot(
settings.get('sdkRoot'), folder?.uri.fsPath);
const adapter = configured || path.join(sdk, 'toolchain', 'sdbg_dap.py');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder?.uri.fsPath,
});
const cwd = folder?.uri.fsPath || path.dirname(adapter);
this.output.appendLine(`Python: ${runtime.command}`);
this.output.appendLine(`DAP: ${adapter}`);
const options = {cwd};
const env = pyenvEnvironment(runtime.command, cwd);
if (env) options.env = env;
return new vscode.DebugAdapterExecutable(
runtime.command, [...runtime.args, adapter], options);
}
}
class SprinterTaskProvider {
createTask(folder, project, definitionOverride) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder.uri.fsPath,
});
const relative = path.relative(folder.uri.fsPath, project);
const definition = definitionOverride ||
{type: 'sprinter', project: relative || '.'};
const options = {cwd: project};
const sdk = resolveSdkRoot(settings.get('sdkRoot'), folder.uri.fsPath);
const env = {SPRINTER_ROOT: sdk, ...(
pyenvEnvironment(runtime.command, project) ||
pyenvEnvironment(runtime.command, folder.uri.fsPath) || {})};
if (env) options.env = env;
const task = new vscode.Task(
definition, folder, taskLabel(project, folder.uri.fsPath), 'sprinter',
new vscode.ProcessExecution(makeCommand(), [
'SRC_DEBUG=1', `PYTHON=${runtime.command}`,
], options), ['$sprinter-sdcc']);
task.group = vscode.TaskGroup.Build;
return task;
}
async provideTasks() {
const folders = vscode.workspace.workspaceFolders || [];
const results = [];
for (const folder of folders) {
const files = await vscode.workspace.findFiles(
new vscode.RelativePattern(folder, '**/Makefile'),
'**/{third_party,mame,libc,libbgi,toolchain}/**');
for (const file of files) {
if (isSprinterMakefile(file.fsPath)) {
results.push(this.createTask(folder, path.dirname(file.fsPath)));
}
}
}
return results;
}
resolveTask(task) {
const folder = task.scope?.uri ? task.scope :
vscode.workspace.workspaceFolders?.[0];
const project = task.definition.project;
if (!folder || typeof project !== 'string') return undefined;
const directory = path.resolve(folder.uri.fsPath, project);
if (!isSprinterMakefile(path.join(directory, 'Makefile'))) return undefined;
return this.createTask(folder, directory, task.definition);
}
}
async function runBuildTask(task) {
const early = [], ended = [];
let execution, resolveResult, completed = false;
const result = new Promise(resolve => {resolveResult = resolve;});
function finish(code) {
if (!completed) {
completed = true;
resolveResult(code);
}
}
const processListener = vscode.tasks.onDidEndTaskProcess(event => {
early.push(event);
if (execution && event.execution === execution) finish(event.exitCode);
});
const endListener = vscode.tasks.onDidEndTask(event => {
ended.push(event);
if (execution && event.execution === execution) {
// При отмене задачи process exit event может не появиться.
setTimeout(() => finish(undefined), 100);
}
});
try {
execution = await vscode.tasks.executeTask(task);
const finished = early.find(event => event.execution === execution);
if (finished) finish(finished.exitCode);
if (ended.some(event => event.execution === execution)) {
setTimeout(() => finish(undefined), 100);
}
return await result;
} finally {
processListener.dispose();
endListener.dispose();
}
}
class SprinterConfigurationProvider {
constructor(tasks, output) {
this.tasks = tasks;
this.output = output;
}
async resolveDebugConfiguration(folder, configuration) {
if (configuration.type !== 'sprinter-mame' ||
configuration.request !== 'launch' ||
configuration.autoBuild === false || configuration.preLaunchTask) {
return configuration;
}
const workspace = folder?.uri.fsPath ||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspace) return configuration;
const mameHome = vscode.workspace.getConfiguration('sprinterDebugger')
.get('mameHome');
if (!configuration.mameHome && mameHome) configuration.mameHome = mameHome;
try {
const project = projectForLaunch(configuration, workspace);
if (project && isSprinterMakefile(path.join(project, 'Makefile'))) {
const scope = folder || vscode.workspace.workspaceFolders[0];
const task = this.tasks.createTask(scope, project);
this.output.appendLine(`Build: ${project}`);
const code = await runBuildTask(task);
if (code !== 0) {
vscode.window.showErrorMessage(
`Сборка Sprinter не прошла (код ${code ?? 'отмена'}); MAME не запущен`);
return undefined;
}
}
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
return undefined;
}
return configuration;
}
}
function activate(context) {
const output = vscode.window.createOutputChannel('Sprinter MAME Debug');
context.subscriptions.push(output);
context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory(
'sprinter-mame', new SprinterAdapterFactory(output)));
const tasks = new SprinterTaskProvider();
context.subscriptions.push(vscode.tasks.registerTaskProvider('sprinter', tasks));
context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(
'sprinter-mame', new SprinterConfigurationProvider(tasks, output)));
context.subscriptions.push(vscode.commands.registerCommand('sprinter.buildActive', async () => {
const editor = vscode.window.activeTextEditor;
const folder = editor && vscode.workspace.getWorkspaceFolder(editor.document.uri);
if (!editor || !folder) {
vscode.window.showErrorMessage('Откройте C-файл проекта Sprinter');
return;
}
let directory = path.dirname(editor.document.uri.fsPath);
const root = folder.uri.fsPath;
while (directory !== path.dirname(directory)) {
if (isSprinterMakefile(path.join(directory, 'Makefile'))) {
try {
await vscode.tasks.executeTask(tasks.createTask(folder, directory));
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
}
return;
}
if (directory === root) break;
directory = path.dirname(directory);
}
vscode.window.showErrorMessage('Не найден проект Sprinter с app.mk');
}));
}
function deactivate() {}
module.exports = {activate, deactivate, SprinterTaskProvider,
SprinterConfigurationProvider, runBuildTask};
@@ -1,134 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
test('F5 выполняет TaskProvider build и не запускает MAME после ошибки', async () => {
let factory, configurationProvider, taskProvider;
let onProcess, onEnd, exitCode = 0;
const errors = [];
const output = {appendLine() {}, dispose() {}};
class DebugAdapterExecutable {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class ProcessExecution {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class Task {
constructor(definition, scope, name, source, execution, problemMatchers) {
Object.assign(this, {definition, scope, name, source,
execution, problemMatchers});
}
}
class RelativePattern {
constructor(folder, pattern) {Object.assign(this, {folder, pattern});}
}
const vscode = {
workspace: {
workspaceFolders: [],
getConfiguration: () => ({get: (key, fallback) => fallback}),
findFiles: async () => [],
},
window: {createOutputChannel: () => output,
showErrorMessage: message => errors.push(message)},
debug: {
registerDebugAdapterDescriptorFactory: (type, value) => {
assert.equal(type, 'sprinter-mame');
factory = value;
return {dispose() {}};
},
registerDebugConfigurationProvider: (type, value) => {
assert.equal(type, 'sprinter-mame');
configurationProvider = value;
return {dispose() {}};
},
},
tasks: {
registerTaskProvider: (type, value) => {
assert.equal(type, 'sprinter');
taskProvider = value;
return {dispose() {}};
},
onDidEndTaskProcess: listener => {
onProcess = listener;
return {dispose() {onProcess = null;}};
},
onDidEndTask: listener => {
onEnd = listener;
return {dispose() {onEnd = null;}};
},
executeTask: async task => {
const execution = {task};
onProcess({execution, exitCode});
onEnd({execution});
return execution;
},
},
commands: {registerCommand: (name) => {
assert.equal(name, 'sprinter.buildActive');
return {dispose() {}};
}},
DebugAdapterExecutable,
ProcessExecution, Task, RelativePattern,
TaskGroup: {Build: 'build'},
};
const source = fs.readFileSync(path.join(__dirname, 'extension.js'), 'utf8');
const module = {exports: {}};
vm.runInNewContext(source, {
require: name => name === 'vscode' ? vscode : require(name),
module, setTimeout,
}, {filename: 'extension.js'});
const context = {subscriptions: []};
module.exports.activate(context);
assert.equal(context.subscriptions.length, 5);
const workspace = path.resolve(__dirname, '..', '..');
const session = {
workspaceFolder: {uri: {fsPath: workspace}},
configuration: {},
};
const descriptor = factory.createDebugAdapterDescriptor(session);
assert.equal(descriptor.command,
path.join(require('node:os').homedir(), '.pyenv', 'shims', 'python'));
assert.equal(descriptor.args.at(-1),
path.join(workspace, 'toolchain', 'sdbg_dap.py'));
assert.equal(descriptor.options.cwd, workspace);
assert.equal(descriptor.options.env.PYENV_VERSION, '3.12');
const configuration = {
type: 'sprinter-mame', request: 'launch',
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
};
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, configuration), configuration, errors.join('\n'));
const task = taskProvider.createTask(session.workspaceFolder,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.name, 'Sprinter: Build tests/hello');
assert.equal(task.execution.command, '/usr/bin/make');
assert.equal(task.execution.options.cwd,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.execution.options.env.PYENV_VERSION, '3.12');
assert.deepEqual(Array.from(task.execution.args),
['SRC_DEBUG=1', `PYTHON=${descriptor.command}`]);
assert.equal(task.problemMatchers[0], '$sprinter-sdcc');
vscode.workspace.workspaceFolders = [session.workspaceFolder];
vscode.workspace.findFiles = async () => [
{fsPath: path.join(workspace, 'tests', 'hello', 'Makefile')},
{fsPath: path.join(workspace, 'Makefile')},
];
const discovered = await taskProvider.provideTasks();
assert.equal(discovered.length, 1);
assert.equal(discovered[0].name, task.name);
const unresolved = {scope: session.workspaceFolder,
definition: {type: 'sprinter', project: 'tests/hello'}};
assert.equal(taskProvider.resolveTask(unresolved).definition,
unresolved.definition);
exitCode = 1;
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, {...configuration}), undefined);
assert.match(errors.at(-1), /MAME не запущен/);
});
@@ -1,205 +0,0 @@
{
"name": "sprinter-mame-debug",
"displayName": "Sprinter MAME Debug",
"description": "Сборка и DAP-отладка C-приложений Sprinter в MAME",
"version": "0.2.0",
"publisher": "sprinter-c-compiler",
"engines": {"vscode": "^1.85.0"},
"categories": ["Debuggers"],
"main": "./extension.js",
"activationEvents": ["onDebug", "onTaskType:sprinter", "onCommand:sprinter.buildActive"],
"contributes": {
"commands": [
{"command": "sprinter.buildActive", "title": "Sprinter: Build Active Project"}
],
"taskDefinitions": [
{
"type": "sprinter",
"required": ["project"],
"properties": {
"project": {
"type": "string",
"description": "Путь к каталогу с Makefile приложения относительно workspace"
}
}
}
],
"problemMatchers": [
{
"name": "sprinter-sdcc",
"owner": "sprinter-sdcc",
"fileLocation": "relative",
"pattern": {
"regexp": "^(.+?):(\\d+):\\s+(error|warning)\\s+(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"severity": 3,
"code": 4,
"message": 5
}
}
],
"configuration": {
"title": "Sprinter MAME Debug",
"properties": {
"sprinterDebugger.pythonCommand": {
"type": "string",
"default": "auto",
"description": "Python 3.12 для DAP; auto выбирает local pyenv shim без зависимости от PATH GUI"
},
"sprinterDebugger.pythonArguments": {
"type": "array",
"items": {"type": "string"},
"default": [],
"description": "Аргументы пользовательской Python-команды перед путём DAP-адаптера"
},
"sprinterDebugger.sdkRoot": {
"type": "string",
"default": "",
"description": "Путь к Sprinter-CC для внешнего проекта; можно задать SPRINTER_ROOT"
},
"sprinterDebugger.mameHome": {
"type": "string",
"default": "",
"description": "Подготовленная среда MAME для launch, если не указана в launch.json"
}
}
},
"debuggers": [
{
"type": "sprinter-mame",
"label": "Sprinter MAME",
"languages": ["c"],
"configurationAttributes": {
"launch": {
"required": ["build"],
"properties": {
"build": {
"type": "string",
"description": "Путь к каталогу .sprinter-cc-NAME"
},
"project": {
"type": "string",
"description": "Каталог с Makefile для сборки перед запуском; определяется из build, если не задан"
},
"autoBuild": {
"type": "boolean",
"default": true,
"description": "Добавить Sprinter build task перед F5, когда найден app.mk"
},
"data": {
"type": "array",
"items": {"type": "string"},
"description": "Дополнительные файлы на debug-дискету"
},
"mame": {
"type": "string",
"description": "Совместимый alias пути к mame.arm"
},
"mameHome": {
"type": "string",
"description": "Среда MAME: стандартные пути к бинарнику, ROM и DSS"
},
"mameBin": {
"type": "string",
"description": "Переопределить бинарник MAME (stock или sdbg)"
},
"mameRompath": {
"type": "string",
"description": "Переопределить каталог ROM/BIOS MAME"
},
"mameDssImage": {
"type": "string",
"description": "Переопределить образ DSS-дискеты"
},
"mameSystemHddImage": {
"type": "string",
"description": "Переопределить системный HDD-образ Sprinter"
},
"mameBios": {
"type": "string",
"default": "v3.06",
"description": "Имя варианта BIOS для -bios"
},
"debugger": {
"type": "string",
"default": "sdbg",
"enum": ["sdbg", "auto", "osx", "windows", "qt", "imgui"],
"enumDescriptions": [
"Только окно Sprinter и управление из VS Code",
"Выбрать доступный штатный backend MAME",
"VS Code вместе с Cocoa debugger MAME на macOS",
"VS Code вместе с native debugger MAME на Windows",
"Qt debugger MAME, если сборка включает USE_QTDEBUG",
"Debugger MAME внутри основного графического окна"
],
"description": "OSD debugger provider; sdbg не открывает Cocoa debugger"
},
"launchAt": {
"type": "number",
"default": 0,
"description": "Не начинать ввод раньше этой секунды эмуляции"
},
"dssTimeout": {
"type": "number",
"default": 30,
"description": "Таймаут появления стабильного prompt DSS"
}
}
},
"attach": {
"required": ["socket"],
"properties": {
"socket": {
"type": "string",
"description": "Unix socket запущенного sdbg_server.py"
},
"adapterPath": {
"type": "string",
"description": "Путь к toolchain/sdbg_dap.py; по умолчанию из workspace"
}
}
}
},
"configurationSnippets": [
{
"label": "Sprinter MAME: Launch",
"description": "Запустить изолированный MAME и остановиться на main",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\""
}
},
{
"label": "Sprinter MAME: Launch + native debugger",
"description": "Запустить VS Code debugger вместе с Cocoa debugger MAME",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch + native debugger",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\"",
"debugger": "osx"
}
},
{
"label": "Sprinter MAME: Attach",
"description": "Подключиться к проверенной sdbg-сессии",
"body": {
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "^\"/tmp/sprinter-sdbg.sock\""
}
}
]
}
]
},
"scripts": {
"check": "node --check extension.js && node --check build.js && node --test runtime.test.js build.test.js extension.test.js"
},
"files": ["extension.js", "runtime.js", "build.js", "README.md"],
"license": "MIT"
}
@@ -1,97 +0,0 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
function executable(filename) {
try {
fs.accessSync(filename, fs.constants.X_OK);
return true;
} catch (_) {
return false;
}
}
function localPythonVersion(workspace) {
if (!workspace) return null;
let directory = path.resolve(workspace);
while (directory !== path.dirname(directory)) {
try {
const version = fs.readFileSync(path.join(directory, '.python-version'),
'utf8').trim().split(/\s+/)[0];
return /^[A-Za-z0-9._-]+$/.test(version) ? version : null;
} catch (_) {}
directory = path.dirname(directory);
}
return null;
}
function installedPyenvPythons(home) {
try {
const versions = fs.readdirSync(path.join(home, '.pyenv', 'versions'));
return versions.filter(version => /^3\.12(?:\.\d+)?$/.test(version))
.sort((a, b) => Number(b.split('.')[2] || 0) -
Number(a.split('.')[2] || 0))
.map(version => path.join(home, '.pyenv', 'versions', version,
'bin', 'python'));
} catch (_) {
return [];
}
}
function resolvePython(options = {}) {
const command = String(options.command || 'auto').trim();
const args = Array.isArray(options.args) ? options.args : [];
if (command !== 'auto') {
return {command, args};
}
const home = options.home || os.homedir();
const workspace = options.workspace;
const isExecutable = options.isExecutable || executable;
const candidates = [];
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = options.localVersion === undefined ?
localPythonVersion(workspace) : options.localVersion;
const installed = options.installedPyenv === undefined ?
installedPyenvPythons(home) : options.installedPyenv;
if (version) candidates.push(shim);
candidates.push(...installed);
if (!version) candidates.push(shim);
if (workspace) {
candidates.push(path.join(workspace, '.venv', 'bin', 'python'));
}
candidates.push(
'/opt/homebrew/bin/python3.12',
'/usr/local/bin/python3.12',
'/usr/bin/python3.12',
);
const found = candidates.find(isExecutable);
if (!found) {
throw new Error(
'Не найден Python 3.12. Задайте абсолютный путь в ' +
'sprinterDebugger.pythonCommand.');
}
return {command: found, args: []};
}
function pyenvEnvironment(command, workspace, home = os.homedir()) {
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = command === shim ? localPythonVersion(workspace) : null;
return version ? {PYENV_VERSION: version} : undefined;
}
function resolveSdkRoot(configured, workspace, environment = process.env) {
const candidates = [configured, environment.SPRINTER_ROOT, workspace]
.filter(Boolean).map(value => path.resolve(value));
const found = candidates.find(root =>
fs.existsSync(path.join(root, 'toolchain', 'sdbg_dap.py')) &&
fs.existsSync(path.join(root, 'bin', 'sprinter-cc')));
if (!found) {
throw new Error('Не найден Sprinter-CC: задайте sprinterDebugger.sdkRoot ' +
'или SPRINTER_ROOT');
}
return found;
}
module.exports = {resolvePython, localPythonVersion, installedPyenvPythons,
pyenvEnvironment, resolveSdkRoot};
@@ -1,67 +0,0 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {resolvePython, localPythonVersion, pyenvEnvironment} = require('./runtime');
test('auto использует pyenv shim без PATH GUI', () => {
const home = path.join(path.sep, 'Users', 'tester');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const result = resolvePython({
command: 'auto',
workspace: path.join(home, 'project'),
home,
isExecutable: filename => filename === shim,
});
assert.deepEqual(result, {command: shim, args: []});
});
test('local pyenv имеет приоритет над случайным .venv', () => {
const home = path.join(path.sep, 'Users', 'tester');
const workspace = path.join(home, 'project');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const venv = path.join(workspace, '.venv', 'bin', 'python');
const result = resolvePython({
command: 'auto', workspace, home,
isExecutable: filename => filename === shim || filename === venv,
});
assert.equal(result.command, shim);
});
test('явная команда и аргументы сохраняются', () => {
const result = resolvePython({
command: '/python/custom',
args: ['-I'],
isExecutable: () => false,
});
assert.deepEqual(result, {command: '/python/custom', args: ['-I']});
});
test('auto сообщает понятную ошибку без Python 3.12', () => {
assert.throws(
() => resolvePython({command: 'auto', isExecutable: () => false}),
/Задайте абсолютный путь/,
);
});
test('внешний workspace без .python-version выбирает установленный pyenv 3.12', () => {
const home = path.join(path.sep, 'Users', 'tester');
const installed = path.join(home, '.pyenv', 'versions', '3.12.14',
'bin', 'python');
const result = resolvePython({
command: 'auto', workspace: '/tmp/external-project', home,
localVersion: null, installedPyenv: [installed],
isExecutable: filename => filename === installed ||
filename === path.join(home, '.pyenv', 'shims', 'python'),
});
assert.equal(result.command, installed);
});
test('shim получает версию workspace при сборке за пределами его дерева', () => {
const workspace = path.resolve(__dirname, '..', '..');
const version = localPythonVersion(workspace);
assert.match(version, /^3\.12/);
const shim = path.join(require('node:os').homedir(), '.pyenv',
'shims', 'python');
assert.deepEqual(pyenvEnvironment(shim, workspace),
{PYENV_VERSION: version});
});