diff --git a/Dockerfile b/Dockerfile index 4a967a9..5873f79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,15 +15,36 @@ COPY src ./src RUN ./gradlew --no-daemon installDist \ && grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version +# whisper.cpp budowany ze źródeł: w repozytoriach Debiana go nie ma, a binarka +# z hosta jest nie do użycia — tam macOS, tu Linux +FROM debian:bookworm-slim AS whisper +RUN apt-get update \ + && apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \ + && git clone --depth 1 --branch v1.7.4 https://github.com/ggerganov/whisper.cpp /src \ + && cmake -S /src -B /src/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \ + && cmake --build /src/build --target whisper-cli -j "$(nproc)" \ + && cmake --install /src/build \ + && rm -rf /var/lib/apt/lists/* /src + FROM eclipse-temurin:21-jre WORKDIR /app # obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen VOLUME ["/data"] -# Transkrypcji mowy w obrazie nie ma i nie będzie: whisper.cpp plus model to -# kilka gigabajtów, a katalog jest w pełni użyteczny bez nich — mówcę i kontekst -# kwestii bierzemy z dialogi.dta. Front powie wprost, że narzędzia brakuje. -# Kto chce transkrybować, robi to na hoście: rex-catalog transcribe. + +# whisper.cpp i ffmpeg wchodzą do obrazu, bo to kilkadziesiąt megabajtów. +# Model NIE wchodzi: waży kilkaset megabajtów do kilku gigabajtów, każdy trzyma +# inny i wybór należy do użytkownika — montuje się go pod /models i wskazuje +# przez WHISPER_MODEL. Bez modelu katalog działa normalnie, a front mówi wprost, +# czego brakuje: mówcę i kontekst kwestii bierzemy z dialogi.dta, nie z modelu. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* +COPY --from=whisper /usr/local/bin/whisper-cli /usr/local/bin/whisper-cli +COPY --from=whisper /usr/local/lib/libwhisper.so* /usr/local/lib/ +COPY --from=whisper /usr/local/lib/libggml*.so /usr/local/lib/ +RUN ldconfig +ENV WHISPER_BIN=/usr/local/bin/whisper-cli # file.encoding ustawia entrypoint przez JAVA_OPTS; JAVA_TOOL_OPTIONS robiłoby # to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu ENV CATALOG_DATA=/data diff --git a/compose.yaml b/compose.yaml index 4e43d23..74d14a5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,8 +9,14 @@ services: # kolekcja tylko do odczytu: katalog niczego w niej nie zmienia, # a obrazy płyt są nieodtwarzalne - ${COLLECTION:-./collection}:/media:ro - # baza i cache odszyfrowanych skryptów — jedyny stan wart trzymania + # baza oraz cache skryptów i miniatur — jedyny stan wart trzymania - catalog-data:/data + # modele whisper.cpp: nie wchodzą do obrazu, bo ważą więcej niż on sam + # i każdy trzyma inny. Bez nich katalog działa, tylko bez transkrypcji. + - ${WHISPER_MODELS:-./models}:/models:ro + environment: + # wskaż konkretny plik modelu, np. ggml-large-v3.bin z podmontowanego katalogu + WHISPER_MODEL: ${WHISPER_MODEL:-/models/ggml-large-v3.bin} restart: unless-stopped volumes: diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index a414433..5ebf12e 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -8,6 +8,7 @@ import pl.genschu.rexcatalog.ingest.LanguageDetector; import pl.genschu.rexcatalog.ingest.MetadataDetector; import pl.genschu.rexcatalog.media.AudioIndexer; import pl.genschu.rexcatalog.media.DialogueIndexer; +import pl.genschu.rexcatalog.media.GraphicsIndexer; import pl.genschu.rexcatalog.script.RefIndexer; import pl.genschu.rexcatalog.transcribe.Transcriber; import pl.genschu.rexcatalog.mcp.McpServer; @@ -38,6 +39,8 @@ public final class Main { System.getProperty("catalog.coreVersion", "nieznana"); public static void main(String[] args) throws Exception { + // dekodowanie grafiki idzie przez AWT; bez tego JVM próbowałby otworzyć ekran + System.setProperty("java.awt.headless", "true"); if (args.length == 0) { usage(); return; @@ -162,11 +165,12 @@ public final class Main { try (Database db = Database.open(dataDir)) { AudioIndexer audio = new AudioIndexer(db); + GraphicsIndexer graphics = new GraphicsIndexer(db, dataDir); DialogueIndexer dialogue = new DialogueIndexer(db); RefIndexer refs = new RefIndexer(db, dataDir); - if (!force && audio.pendingCount() == 0 && dialogue.pendingCount() == 0 - && refs.pendingCount() == 0) { + if (!force && audio.pendingCount() == 0 && graphics.pendingCount() == 0 + && dialogue.pendingCount() == 0 && refs.pendingCount() == 0) { System.out.println("Nic do przerobienia — wszystko już zbadane."); return; } @@ -178,6 +182,12 @@ public final class Main { audioStats.audio(), audioStats.archives(), audioStats.entries(), audioStats.failed()); + System.out.println("Dekoduję grafikę i buduję miniatury..."); + GraphicsIndexer.Stats graphicsStats = graphics.run(force); + System.out.printf(" %d obrazów, %d animacji (%d klatek), nieczytelnych: %d%n", + graphicsStats.images(), graphicsStats.animations(), graphicsStats.frames(), + graphicsStats.failed()); + System.out.println("Czytam tabele dialogów..."); DialogueIndexer.Stats dialogueStats = dialogue.run(force); System.out.printf(" %d plików, %d wierszy, błędów: %d%n", @@ -632,7 +642,7 @@ public final class Main { stats statystyki bazy decode [--force] odszyfruj skrypty do cache'u i indeksu - probe [--force] zbadaj dźwięk, archiwa, dialogi i odwołania + probe [--force] zbadaj dźwięk, grafikę, dialogi i odwołania transcribe [--edition N] [--limit N] [--redo] rozpoznaj mowę przez whisper.cpp (wynik maszynowy) analyze wyciągnij metadane z application.def diff --git a/src/main/java/pl/genschu/rexcatalog/db/Database.java b/src/main/java/pl/genschu/rexcatalog/db/Database.java index 4f6abe7..f8686b0 100644 --- a/src/main/java/pl/genschu/rexcatalog/db/Database.java +++ b/src/main/java/pl/genschu/rexcatalog/db/Database.java @@ -85,9 +85,10 @@ public final class Database implements AutoCloseable { } } + /** {@code table_xinfo}, nie {@code table_info}: to drugie pomija kolumny wyliczane. */ private boolean hasColumn(String table, String column) throws SQLException { try (Statement st = connection.createStatement(); - ResultSet rs = st.executeQuery("PRAGMA table_info(" + table + ")")) { + ResultSet rs = st.executeQuery("PRAGMA table_xinfo(" + table + ")")) { while (rs.next()) { if (column.equalsIgnoreCase(rs.getString("name"))) { return true; @@ -104,6 +105,36 @@ public final class Database implements AutoCloseable { */ private void migrate() throws SQLException { addColumnIfMissing("copy", "fs_type", "TEXT"); + // media zaczęło od dźwięku, ale grafika opisuje się tymi samymi kategoriami: + // fakt o blobie, kluczowany odciskiem treści + addColumnIfMissing("media", "width", "INTEGER"); + addColumnIfMissing("media", "height", "INTEGER"); + addColumnIfMissing("media", "frames", "INTEGER"); + addColumnIfMissing("media", "fps", "INTEGER"); + addColumnIfMissing("media", "author", "TEXT"); + addBasenameIndex(); + } + + /** + * Nazwa pliku bez katalogu, jako kolumna wyliczana i zaindeksowana. + * + *
Rozwiązywanie odwołań ze skryptów pyta o nią raz na odwołanie, a jeden + * skrypt potrafi ich mieć siedemset — bez indeksu każde z nich przeglądało + * wszystkie 38 tysięcy ścieżek. Kolumna jest wirtualna, więc nie zajmuje + * miejsca i nie wymaga uzupełniania przy zapisie. + */ + private void addBasenameIndex() throws SQLException { + if (!hasColumn("file", "basename")) { + try (Statement st = connection.createStatement()) { + st.executeUpdate("ALTER TABLE file ADD COLUMN basename TEXT " + + "GENERATED ALWAYS AS " + + "(replace(path, rtrim(path, replace(path, '/', '')), '')) VIRTUAL"); + } + } + try (Statement st = connection.createStatement()) { + st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_file_basename " + + "ON file(copy_id, basename)"); + } } private void addColumnIfMissing(String table, String column, String type) throws SQLException { @@ -371,6 +402,18 @@ public final class Database implements AutoCloseable { sha1 UNINDEXED, tokenize = "unicode61 remove_diacritics 2 tokenchars '_'" ) + """, + // Nazwane sekwencje w animacji: BEZRUCH, GADA_START, GADA_1... + // Mówią, co postać potrafi zrobić, więc są treścią, a nie tylko liczbą. + """ + CREATE TABLE IF NOT EXISTS anim_event ( + ann_sha1 TEXT NOT NULL, + name TEXT NOT NULL, + frames_count INTEGER, + loop_start INTEGER, + loop_end INTEGER, + PRIMARY KEY (ann_sha1, name) + ) """ }; } diff --git a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java index 966032c..e26d035 100644 --- a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java +++ b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java @@ -98,7 +98,8 @@ public final class Tools { "Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy " + "i tablice. Przy kwestiach mówionych podaje długość nagrania, " + "postać, która je wypowiada, i zdarzenie, przy którym padają — " - + "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść.", + + "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść. " + + "Przy grafice: wymiary, liczbę klatek animacji i autora.", schema(Map.of( "ref", property("string", "SHA-1 skryptu albo fragment ścieżki"), "format", property("string", "Zawęź do formatu, np. WAV") @@ -149,32 +150,20 @@ public final class Tools { sb.append(String.join(", ", search.pathsFor(sha1))).append('\n') .append(String.join(", ", search.gamesFor(sha1))).append("\n\n"); - String sql = """ - WITH kopia AS (SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1) - SELECT r.object, r.field, r.value, r.format, r.line, - COALESCE( - (SELECT f2.blob_sha1 FROM file f2, kopia - WHERE f2.copy_id = kopia.copy_id - AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1), - (SELECT ae.sha1 FROM archive_entry ae - JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia - WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1) - ) AS target, - (SELECT d.speaker FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1) - AS speaker, - (SELECT d.trigger FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1) - AS trigger - FROM script_ref r WHERE r.script_sha1 = ? - """ + (format == null || format.isBlank() ? "" : " AND r.format = ?\n") + String sql = pl.genschu.rexcatalog.media.MediaIndex.RESOLVE_SQL + + (format == null || format.isBlank() ? "" : " AND r.format = ?5\n") + "ORDER BY r.format, r.line"; int missing = 0; int shown = 0; + var place = new pl.genschu.rexcatalog.media.MediaIndex(db).place(sha1); + if (place == null) { + return sb + "Tego skryptu nie ma w żadnej kopii."; + } try (PreparedStatement st = db.connection().prepareStatement(sql)) { - st.setString(1, sha1); - st.setString(2, sha1); + pl.genschu.rexcatalog.media.MediaIndex.bindResolve(st, place, sha1); if (format != null && !format.isBlank()) { - st.setString(3, format.toUpperCase(java.util.Locale.ROOT)); + st.setString(5, format.toUpperCase(java.util.Locale.ROOT)); } try (ResultSet rs = st.executeQuery()) { while (rs.next()) { @@ -204,18 +193,37 @@ public final class Tools { return sb.toString(); } - /** Jedna linijka faktów o nagraniu: ile trwa, kto mówi, co słychać. */ + /** Jedna linijka faktów o zasobie: ile trwa albo jak duży jest, kto mówi, co widać. */ private String audioDetail(String sha1, String speaker, String trigger) throws Exception { try (PreparedStatement st = db.connection().prepareStatement(""" - SELECT m.duration_ms, m.codec, t.text, t.model + SELECT m.kind, m.duration_ms, m.codec, m.width, m.height, m.frames, m.fps, + m.author, t.text, t.model FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1 - WHERE m.sha1 = ? AND m.kind = 'audio' + WHERE m.sha1 = ? """)) { st.setString(1, sha1); try (ResultSet rs = st.executeQuery()) { if (!rs.next()) { return ""; } + String kind = rs.getString("kind"); + if ("image".equals(kind) || "anim".equals(kind)) { + StringBuilder graphic = new StringBuilder(); + graphic.append(rs.getInt("width")).append('×').append(rs.getInt("height")); + if ("anim".equals(kind)) { + graphic.append(" · ").append(rs.getInt("frames")).append(" klatek"); + if (rs.getInt("fps") > 0) { + graphic.append(" · ").append(rs.getInt("fps")).append(" kl./s"); + } + } + if (rs.getString("author") != null) { + graphic.append(" · ").append(rs.getString("author")); + } + return graphic.toString(); + } + if (!"audio".equals(kind)) { + return ""; + } StringBuilder sb = new StringBuilder(); sb.append(seconds(rs.getInt("duration_ms"))).append(' ') .append(rs.getString("codec")); diff --git a/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java new file mode 100644 index 0000000..085f508 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java @@ -0,0 +1,291 @@ +package pl.genschu.rexcatalog.media; + +import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher; +import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem; +import pl.genschu.rexcatalog.db.Database; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Buduje miniatury obrazów i animacji oraz zapisuje ich fakty. + * + *
W cache'u ląduje tylko miniatura — pełny obraz odtwarzamy z płyty na żądanie. + * Inaczej 10 459 plików rozpakowanych do PNG zajęłoby więcej niż sama kolekcja, + * a odczyt z obrazu i tak trwa milisekundy. + * + *
Animacje dostają dodatkowo listę nazwanych zdarzeń: {@code BEZRUCH},
+ * {@code GADA_START}, {@code GADA_1} — to opis tego, co postać potrafi zrobić,
+ * więc traktujemy je jak treść, a nie jak liczbę klatek.
+ */
+public final class GraphicsIndexer {
+
+ public static final String VERSION = "grafika-1";
+
+ private static final String KIND = "preview";
+
+ /** Dłuższy bok miniatury. Podgląd ma się otwierać od razu, nie ładować. */
+ private static final int THUMBNAIL = 320;
+
+ private final Database db;
+ private final Path dataDir;
+
+ public GraphicsIndexer(Database db, Path dataDir) {
+ this.db = db;
+ this.dataDir = dataDir;
+ }
+
+ public record Stats(int images, int animations, int frames, int failed) {
+ }
+
+ private record Pending(String pathInContainer, String sha1, String format) {
+ }
+
+ public Stats run(boolean force) throws Exception {
+ Map Reguły są takie same jak w Piklibie i nie są heurystyką:
+ * Bez tego rozróżnienia nazwa {@code bkg.img} jest bezużyteczna: w samej
+ * Wojnie Trojańskiej jest ich siedemnaście, po jednym na planszę.
+ * Dopasowanie po samej nazwie zostaje jako ostatnia deska ratunku i jest
+ * oznaczane jako niepewne.
+ *
+ * Parametry: katalog skryptu, korzeń gry, identyfikator kopii (kilka razy)
+ * i odcisk skryptu — podane wprost, a nie liczone we wspólnym wyrażeniu, bo
+ * SQLite przeliczałby je dla każdego z kilkuset odwołań osobno.
+ */
+ public static final String RESOLVE_SQL = """
+ SELECT r.object, r.field, r.value, r.name, r.format, r.line,
+ COALESCE(
+ -- $ = korzeń gry
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4 AND r.value LIKE '$%'
+ AND f2.path = ?2 || ltrim(substr(lower(replace(r.value, '\', '/')), 2), '/')
+ LIMIT 1),
+ -- obok skryptu
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4 AND f2.path = ?1 || r.name LIMIT 1),
+ -- ścieżka względna wobec katalogu skryptu
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4
+ AND f2.path = ?1 || lower(replace(r.value, '\', '/')) LIMIT 1),
+ -- dźwięk mowy: katalog wavs w korzeniu gry
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4 AND r.format = 'WAV'
+ AND f2.path = ?2 || 'wavs/' || r.name LIMIT 1),
+ -- muzyka bywa wprost w korzeniu
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4 AND f2.path = ?2 || r.name LIMIT 1),
+ -- kwestie spakowane do archiwum wav.snd
+ (SELECT ae.sha1 FROM archive_entry ae
+ JOIN file f3 ON f3.blob_sha1 = ae.container_sha1
+ WHERE f3.copy_id = ?4 AND ae.name = r.name LIMIT 1),
+ -- ostatnia deska ratunku: ta sama nazwa gdziekolwiek w kopii
+ (SELECT f2.blob_sha1 FROM file f2
+ WHERE f2.copy_id = ?4 AND f2.basename = r.name LIMIT 1)
+ ) AS target,
+ (SELECT COUNT(DISTINCT f2.blob_sha1) FROM file f2
+ WHERE f2.copy_id = ?4 AND f2.basename = r.name) AS wariantow,
+ (SELECT 1 FROM file f2
+ WHERE f2.copy_id = ?4
+ AND (f2.path = ?1 || r.name
+ OR f2.path = ?1 || lower(replace(r.value, '\', '/'))
+ OR (r.value LIKE '$%' AND f2.path = ?2
+ || ltrim(substr(lower(replace(r.value, '\', '/')), 2), '/'))
+ OR (r.format = 'WAV' AND f2.path = ?2 || 'wavs/' || r.name)
+ OR f2.path = ?2 || r.name)
+ LIMIT 1) AS pewne,
+ (SELECT d.speaker FROM dialogue_line d
+ WHERE d.audio_name = r.name LIMIT 1) AS speaker,
+ (SELECT d.trigger FROM dialogue_line d
+ WHERE d.audio_name = r.name LIMIT 1) AS trigger
+ FROM script_ref r
+ WHERE r.script_sha1 = ?3
+ """;
+
+ /** Katalog skryptu, korzeń gry i kopia — kontekst potrzebny do {@link #RESOLVE_SQL}. */
+ public record ScriptPlace(long copyId, String dir, String root) {
+ }
+
+ /**
+ * Ustala, gdzie w kopii leży skrypt i który korzeń gry go obejmuje.
+ * Na dwupacku liczy się korzeń najgłębszy z pasujących.
+ */
+ public ScriptPlace place(String scriptSha1) throws Exception {
+ try (PreparedStatement st = db.connection().prepareStatement("""
+ SELECT f.copy_id,
+ CASE WHEN instr(f.path, '/') > 0
+ THEN rtrim(f.path, replace(f.path, '/', ''))
+ ELSE '' END AS dir,
+ COALESCE((
+ SELECT CASE WHEN g.root_path = '' THEN ''
+ ELSE g.root_path || '/' END
+ FROM game_root g
+ WHERE g.copy_id = f.copy_id
+ AND (g.root_path = '' OR f.path LIKE g.root_path || '/%')
+ ORDER BY length(g.root_path) DESC LIMIT 1
+ ), '') AS root
+ FROM file f WHERE f.blob_sha1 = ? LIMIT 1
+ """)) {
+ st.setString(1, scriptSha1);
+ try (ResultSet rs = st.executeQuery()) {
+ return rs.next()
+ ? new ScriptPlace(rs.getLong("copy_id"), rs.getString("dir"),
+ rs.getString("root"))
+ : null;
+ }
+ }
+ }
+
+ /** Wiąże parametry {@link #RESOLVE_SQL} w ustalonej kolejności. */
+ public static void bindResolve(PreparedStatement st, ScriptPlace place, String scriptSha1)
+ throws java.sql.SQLException {
+ st.setString(1, place.dir());
+ st.setString(2, place.root());
+ st.setString(3, scriptSha1);
+ st.setLong(4, place.copyId());
+ }
+
/** Odczytuje zasób po odcisku treści; {@code null}, gdy nie ma go w katalogu. */
public Clip read(String sha1) throws Exception {
Location location = locate(sha1);
@@ -71,12 +185,11 @@ public final class MediaIndex {
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT blob_sha1 FROM file
- WHERE copy_id = ? AND (path = ? OR path LIKE '%/' || ?)
+ WHERE copy_id = ? AND basename = ?
LIMIT 1
""")) {
st.setLong(1, copyId);
st.setString(2, needle);
- st.setString(3, needle);
try (ResultSet rs = st.executeQuery()) {
if (rs.next()) {
return rs.getString(1);
diff --git a/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java
new file mode 100644
index 0000000..433aeb7
--- /dev/null
+++ b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java
@@ -0,0 +1,361 @@
+package pl.genschu.rexcatalog.media;
+
+import pl.genschu.bloomooemulator.encoding.CLZW2Compression;
+import pl.genschu.bloomooemulator.encoding.CRLECompression;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Dekoduje grafikę Aidem — obrazy {@code PIK\0} i animacje {@code NVP\0} —
+ * do zwykłych obrazów Javy, bez OpenGL-a.
+ *
+ * Rozpakowanie bierzemy z {@code :core} ({@link CLZW2Compression},
+ * {@link CRLECompression}), bo to najtrudniejsza część i nie ma sensu jej
+ * powielać. Nagłówki czytamy tutaj, i to nie z wyboru: {@code ImageLoader}
+ * trzyma parser w metodzie prywatnej, a {@code AnimoLoader} — mimo że jego
+ * sygnatura wygląda na niezależną od grafiki — buduje obiekty {@code Image},
+ * których konstruktor od razu tworzy {@code Texture}. W procesie bez kontekstu
+ * OpenGL-a jedno i drugie jest nie do użycia. Układ pól odwzorowuje te loadery
+ * jeden do jednego, żeby katalog i emulator czytały te same bajty tak samo.
+ */
+public final class PikGraphics {
+
+ private static final Charset LATIN1 = StandardCharsets.ISO_8859_1;
+
+ /** Rodzaje kompresji, tak jak numeruje je {@code Image.CompressionTypes}. */
+ private static final int NONE = 0;
+ private static final int CLZW = 2;
+ private static final int CRLE_IN_CLZW = 3;
+ private static final int CRLE = 4;
+ private static final int JPEG = 5;
+
+ private PikGraphics() {
+ }
+
+ public record Picture(int width, int height, int colorDepth, int compression,
+ int offsetX, int offsetY, BufferedImage image) {
+ }
+
+ public record Frame(String name, int width, int height, int offsetX, int offsetY,
+ int compression, BufferedImage image) {
+ }
+
+ /** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
+ public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd,
+ List Kolor to dwa bajty na piksel (RGB565 albo RGB555, zależnie od głębi),
+ * alfa — jeden bajt. Odwzorowanie kanałów jest takie samo jak
+ * w {@code Image.convertRgbToRgb888}.
+ */
+ static BufferedImage compose(int width, int height, int colorDepth, int compression,
+ byte[] imageData, byte[] alphaData) throws IOException {
+ if (width <= 0 || height <= 0) {
+ throw new IOException("nieprawdopodobne wymiary: " + width + "x" + height);
+ }
+
+ if (compression == JPEG) {
+ return composeJpeg(imageData, alphaData, width, height);
+ }
+
+ byte[] pixels = imageData;
+ byte[] alpha = alphaData;
+ if (compression == CRLE) {
+ pixels = CRLECompression.decodeCRLE(pixels, 2);
+ if (alpha != null) {
+ alpha = CRLECompression.decodeCRLE(alpha);
+ }
+ }
+ if (compression == CLZW || compression == CRLE_IN_CLZW) {
+ pixels = CLZW2Compression.decompress(pixels);
+ if (alpha != null) {
+ alpha = CLZW2Compression.decompress(alpha);
+ }
+ if (compression == CRLE_IN_CLZW) {
+ pixels = CRLECompression.decodeCRLE(pixels, 2);
+ if (alpha != null) {
+ alpha = CRLECompression.decodeCRLE(alpha);
+ }
+ }
+ }
+
+ BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+ int count = width * height;
+ for (int i = 0; i < count; i++) {
+ int at = i * 2;
+ if (at + 1 >= pixels.length) {
+ break;
+ }
+ int packed = (pixels[at] & 0xFF) | ((pixels[at + 1] & 0xFF) << 8);
+ int a = alpha != null && i < alpha.length ? alpha[i] & 0xFF : 255;
+ out.setRGB(i % width, i / width, (a << 24) | rgb(packed, colorDepth));
+ }
+ return out;
+ }
+
+ private static BufferedImage composeJpeg(byte[] imageData, byte[] alphaData,
+ int width, int height) throws IOException {
+ BufferedImage jpeg = ImageIO.read(new ByteArrayInputStream(imageData));
+ if (jpeg == null) {
+ throw new IOException("nieczytelny strumień JPEG");
+ }
+ byte[] alpha = alphaData == null ? null : CLZW2Compression.decompress(alphaData);
+
+ BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ int rgb = x < jpeg.getWidth() && y < jpeg.getHeight()
+ ? jpeg.getRGB(x, y) & 0xFFFFFF : 0;
+ int index = y * width + x;
+ int a = alpha != null && index < alpha.length ? alpha[index] & 0xFF : 255;
+ out.setRGB(x, y, (a << 24) | rgb);
+ }
+ }
+ return out;
+ }
+
+ /** RGB565 albo RGB555 rozciągnięte na pełne osiem bitów na kanał. */
+ private static int rgb(int packed, int colorDepth) {
+ int r;
+ int g;
+ int b;
+ if (colorDepth == 15) {
+ r = (packed >> 10) & 0x1F;
+ g = (packed >> 5) & 0x1F;
+ b = packed & 0x1F;
+ r = (r << 3) | (r >> 2);
+ g = (g << 3) | (g >> 2);
+ b = (b << 3) | (b >> 2);
+ } else {
+ r = (packed >> 11) & 0x1F;
+ g = (packed >> 5) & 0x3F;
+ b = packed & 0x1F;
+ r = (r << 3) | (r >> 2);
+ g = (g << 2) | (g >> 4);
+ b = (b << 3) | (b >> 2);
+ }
+ return (r << 16) | (g << 8) | b;
+ }
+
+ // ---------- odczyt ----------
+
+ /** Czytnik po tablicy bajtów; strumieniowy z {@code :core} nie umie cofać. */
+ private static final class Reader {
+ private final byte[] data;
+ private int at;
+
+ Reader(byte[] data) {
+ this.data = data;
+ }
+
+ int u8() throws IOException {
+ need(1);
+ return data[at++] & 0xFF;
+ }
+
+ int u16() throws IOException {
+ need(2);
+ int value = (data[at] & 0xFF) | ((data[at + 1] & 0xFF) << 8);
+ at += 2;
+ return value;
+ }
+
+ int i16() throws IOException {
+ return (short) u16();
+ }
+
+ int i32() throws IOException {
+ need(4);
+ int value = (data[at] & 0xFF) | ((data[at + 1] & 0xFF) << 8)
+ | ((data[at + 2] & 0xFF) << 16) | ((data[at + 3] & 0xFF) << 24);
+ at += 4;
+ return value;
+ }
+
+ void skip(int count) throws IOException {
+ need(count);
+ at += count;
+ }
+
+ byte[] bytes(int count) throws IOException {
+ if (count < 0) {
+ throw new IOException("ujemna długość bloku: " + count);
+ }
+ need(count);
+ byte[] out = new byte[count];
+ System.arraycopy(data, at, out, 0, count);
+ at += count;
+ return out;
+ }
+
+ /** Magiczne bajty kończy zero, więc porównujemy je wprost, a nie jako tekst. */
+ void expectMagic(String magic, String what) throws IOException {
+ byte[] raw = bytes(4);
+ boolean ok = raw[3] == 0;
+ for (int i = 0; ok && i < 3; i++) {
+ ok = raw[i] == (byte) magic.charAt(i);
+ }
+ if (!ok) {
+ throw new IOException("to nie jest " + what + " " + magic
+ + " (nagłówek: " + new String(raw, LATIN1).replace("\0", "\\0") + ")");
+ }
+ }
+
+ String fixedString(int count) throws IOException {
+ byte[] raw = bytes(count);
+ int end = 0;
+ while (end < raw.length && raw[end] != 0) {
+ end++;
+ }
+ return new String(raw, 0, end, LATIN1);
+ }
+
+ String lengthPrefixed() throws IOException {
+ return new String(bytes(i32()), LATIN1).replace("\0", "");
+ }
+
+ private void need(int count) throws IOException {
+ if (at + count > data.length) {
+ throw new IOException("plik urwany na pozycji " + at);
+ }
+ }
+ }
+}
diff --git a/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java
index 7348b13..5b2d222 100644
--- a/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java
+++ b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java
@@ -38,8 +38,14 @@ public final class RefIndexer {
"CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA",
"KIMODEL", "KIANIM");
+ /**
+ * Nazwa pola bywa zakończona daszkiem i numerem — {@code VARIWST:ONCHANGED^2=...}
+ * to kolejny wariant tego samego zdarzenia. Bez daszka w zbiorze znaków takie
+ * linie w ogóle nie trafiałyby do przeszukania, a siedzą w nich prawdziwe
+ * odwołania, choćby {@code SETBACKGROUND("PARYZ\BKG1.IMG")}.
+ */
private static final Pattern ASSIGNMENT =
- Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_]+)=(.*)$");
+ Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_^]+)=(.*)$");
/**
* Nazwa nie może być doklejona plusem: {@code "$COMMON\"+ZMIENNA+"_DEF.DTA"}
@@ -48,7 +54,10 @@ public final class RefIndexer {
* _WZIECIE_JABLKA.WAV} to prawdziwy plik.
*/
private static final Pattern FILENAME = Pattern.compile(
- "(? entry : pending.entrySet()) {
@@ -93,8 +111,15 @@ public final class RefIndexer {
StandardCharsets.UTF_8);
delete.setString(1, sha1);
delete.executeUpdate();
- refs += extract(sha1, body, insert);
+ int written = extract(sha1, body, insert);
+ refs += written;
scripts++;
+
+ mark.setString(1, sha1);
+ mark.setString(2, written + " odwołań do zasobów");
+ mark.setString(3, VERSION);
+ mark.setString(4, java.time.Instant.now().toString());
+ mark.executeUpdate();
} catch (Exception e) {
System.err.printf(" ! %s: %s%n", sha1, e.getMessage());
failed++;
@@ -152,16 +177,21 @@ public final class RefIndexer {
String sql = """
SELECT a.blob_sha1, a.path
FROM artifact a
+ LEFT JOIN media m ON m.sha1 = a.blob_sha1 AND m.kind = 'script'
WHERE a.kind = 'script'
""" + (force ? "" : """
- AND NOT EXISTS (SELECT 1 FROM script_ref r WHERE r.script_sha1 = a.blob_sha1)
+ AND (m.sha1 IS NULL OR m.tool_version <> ?)
""");
Map
+ *
+ *
+ *