Завершить разделение SDK и внешних проектов
This commit is contained in:
@@ -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
|
||||
@@ -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 ...
|
||||
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог относительно
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,7 +27,7 @@ png_strip.py — генерик-конвертер произвольного н
|
||||
следующего вызова. Так несколько независимо
|
||||
собранных лент (напр. разные типы тайлов) можно
|
||||
свести в ОДНУ палитру для одновременного показа
|
||||
на экране (см. applications/PoP/poc/tools/
|
||||
на экране (см. ../Applications/PoP-Archive/poc/tools/
|
||||
build_room_palette.py).
|
||||
|
||||
Каждая картинка — источник ОДНОГО кадра ленты (не атлас-сетка;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,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). Не завязан ни на какой конкретный проект/игру — просто
|
||||
берёт раскладку тайлов, которую вы сами опишете, и картинки, которые
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -13,9 +13,9 @@ docs/size_baseline.tsv.
|
||||
программы (нет в эталоне) — предупреждение, добавить через --update.
|
||||
Запускать после `make all`.
|
||||
|
||||
ТОЛЬКО tests/, без examples/: регресс ловит разжирение libc, а для этого
|
||||
ТОЛЬКО tests/, без отдельного репозитория Examples: регресс ловит разжирение libc, а для этого
|
||||
хватает мелких тестов — каждый тянет свой кусок библиотеки и пересобирается
|
||||
за секунды. Крупные приложения (examples/mdview компилируется минутами)
|
||||
за секунды. Крупные приложения (`../Examples/mdview` компилируется минутами)
|
||||
не показывают ничего сверх этого и лишь удлиняют цикл, поэтому и из
|
||||
`make all` они убраны (см. корневой Makefile).
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
@@ -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-позиции.
|
||||
@@ -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});
|
||||
});
|
||||
Reference in New Issue
Block a user