diff --git a/CRUSH.md b/CRUSH.md index 67df6ac..b3fb662 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -1,7 +1,7 @@ # Grimoire — Project context -What follows is hard-won knowledge from building M0 (injecting native ink -into reMarkable .rm v6 pages). Do not unlearn any of this. +What follows is hard-won knowledge from building the grimoire closed loop +on reMarkable 2. Do not unlearn any of this. ## Coordinates @@ -10,98 +10,142 @@ into reMarkable .rm v6 pages). Do not unlearn any of this. - `x = 0` is the horizontal center of the page; negative values go left, positive go right. Range is roughly -702 to 701 on a standard page. - `y = 0` is the top of the page, increasing downward. Range is 0 to 1871. -- Real handwriting in existing notebooks shows x in the -300 to 300 range - and y in the 700 to 1050 range (roughly the middle third of the page). -- Source: reMarkable-kaitai `rmv6.ksy` spec; confirmed by inspecting actual - device files and rendering them. ## Stroke parameters (matching real ink) - Tool: `Pen.BALLPOINT_2` (value 15) - Color: `PenColor.BLACK` (value 0) -- `thickness_scale`: 2.0 -- `starting_length`: 0.0 -- Point `width`: 9 -- Point `pressure`: 150 + small variance (0-30) -- Point `speed`: 5 + small variance (0-10) -- Point `direction`: 90 + small variance -- These values were tuned by comparing against strokes from a real notebook - ("9.8" on Kieran's device). Real pen widths range 12-18, pressures - 0-255 averaging ~173. - -## File format injection (the append strategy) - -- **Never re-serialize original blocks.** `read_blocks()` followed by - `write_blocks()` loses data. rmscene warns "Some data has not been read. - The data may have been written using a newer format than this reader - supports." Respect this warning — re-serialized files crash xochitl. -- Instead: serialize only your new `SceneLineItemBlock`s via - `write_blocks()`, strip the 43-byte header it emits, and append the - remaining bytes to the original file. -- Layer 1 is always `CrdtId(0, 11)`. Inject `SceneLineItemBlock`s directly - into it as children. Do not create new SceneTreeBlocks or TreeNodeBlocks - — xochitl doesn't need them and they caused crashes in early experiments. -- Reuse author 1 (always present). No need to modify `AuthorIdsBlock`. -- CRDT sequence IDs starting at 200 for our blocks — safely above anything - in real files (which use low single digits). +- `thickness_scale`: 2.0, `starting_length`: 0.0 +- Point `width`: 10–13, Point `pressure`: 160–190 +- Point `speed`: 4–12, Point `direction`: 80–110 +- Natural variation: y_jitter (-1.5 to 0.5), slant (-0.015 to 0.015), + wobble amplitude 0.8 -## Device management +## Font rendering -- `.content` file has `"sizeInBytes"` that must match the `.rm` file size - or xochitl crashes on load. Fix with sed before restart: - ``` - sed -i 's/"sizeInBytes": "[0-9]*"/"sizeInBytes": "NNNN"/' doc.content - ``` -- Too many `systemctl restart xochitl` calls trigger rate limiting. Use: - ``` - systemctl reset-failed xochitl && systemctl restart xochitl - ``` -- Busybox on the device is limited: no `base64`, `head` needs `-n N` syntax, - no `bash`, limited `find`. Use `od`/`hexdump` for binary inspection. -- The SSH MCP gets new session IDs on each connect. Old sessions die when - the device reboots or the connection drops. Always use the latest. - -## M0 script (`grimoire.py`) - -- Single-file Python script, no dependencies beyond `rmscene` (installed in - `./.venv`). -- Hershey simplex font subset: 21 glyphs (A-Z uppercase plus some - punctuation). -- Hershey glyph coordinates are in a 0..21 x 0..32 grid per glyph, - `GLYPH_WIDTH=21`, `GLYPH_SPACING=8`. -- Default text: "GRIMOIRE SAYS HELLO" at scale 1.1, origin (-300, 700). -- Usage: - ``` - python grimoire.py input.rm output.rm - ``` - -## Deploy workflow (from mac to device) +- EMS Allure SVG single-stroke font (from GitLab `oskay/svg-fonts`). +- Straight-line paths only (M/L commands), no beziers. +- Per-glyph advance widths from `horiz-adv-x` attribute (not fixed width). +- Y-axis flip: SVG uses Y-up, `.rm` v6 uses Y-down. Apply `sy = -gy * scale`. +- Render params: `DEFAULT_SCALE = 0.07`, `DEFAULT_X = -550.0`, + `DEFAULT_Y = 850.0`, `LINE_HEIGHT = 1.4`, `MAX_LINE_WIDTH = 1100`. +- Word wrapping implemented. Preview mode: `--preview "text"` renders SVG. +- Font files: `fonts/EMSAllure.svg` (primary), `EMSCasualHand.svg`, `EMSInvite.svg`. -```sh -# 1. Generate reply -.venv/bin/python3.13 grimoire.py pages/original.rm pages/replied.rm +## File format injection -# 2. Push to device (adjust paths — get UUID from notebook metadata) -scp pages/replied.rm root@10.11.99.1:/path/to/page.rm +- **Never re-serialize original blocks.** `read_blocks()` → `write_blocks()` + loses data. Serialize only new blocks, strip 43-byte header, append to raw + original bytes. +- Reuse author 1 (always present). CRDT sequence IDs starting at 200+. -# 3. Fix metadata and restart -ssh root@10.11.99.1 \ - "sed -i 's/\"sizeInBytes\": \"[0-9]*\"/\"sizeInBytes\": \"3959\"/' /path/to/doc.content \ - && systemctl reset-failed xochitl \ - && systemctl restart xochitl" -``` +### Layer-based injection (working) -## Notebook UUID for testing +Real reMarkable layers require specific block structure: +1. `SceneTreeBlock(tree_id=LAYER_ID, node_id=CrdtId(0,0), is_update=True, parent=ROOT)` — content tree root +2. `TreeNodeBlock(node_id=LAYER_ID, label="name", visible=True)` — metadata +3. `SceneGroupItemBlock(parent=ROOT, value=LAYER_ID, left_id=LAST_CHILD)` — links into sibling list +4. `SceneLineItemBlock(parent_id=LAYER_ID, ...)` — strokes parented to tree_id + +Key: `left_id` in SceneGroupItemBlock must chain to the previous root child +for correct ordering. Use `_find_last_root_child()` to scan existing blocks. + +## Device management + +- SSH: `root@10.11.99.1` (config alias `remarkable`) +- `.content` file `sizeInBytes` must match `.rm` file size or xochitl crashes. +- `systemctl reset-failed xochitl && systemctl restart xochitl` to avoid rate limiting. +- Busybox limitations: `head -n N` syntax, no `base64`, limited `find`. +- **Page navigation reload**: writing a modified `.rm` to disk + fixing + `sizeInBytes` is picked up when user navigates away and back. No restart + needed. ~2s vs ~12s for full restart. + +## Test notebook -Kieran's Grrimoire test notebook: - UUID: `5c81a1df-c36c-4eea-9289-978dfcc655a1` - Page: `5b66f39c-9d00-40a7-bce0-9fb3a044d446.rm` - Path: `/home/root/.local/share/remarkable/xochitl/5c81a1df-c36c-4eea-9289-978dfcc655a1/` -## Open issues from M0 +## Xovi extension (`xovi-ext/grimoire-injector/`) + +Native C++ extension that runs inside xochitl's process via LD_PRELOAD. + +### What it does +- Registers `GrimoireInjector` QML singleton at `dev.grimoire.injector 0.1` +- Spawns a pthread that polls `/tmp/grimoire_strokes.json` every 1s +- On file change, uses `QMetaObject::invokeMethod(Qt::QueuedConnection)` to + call back into the main thread for stroke loading +- Creates `SceneLineItem` objects using reverse-engineered structs from + clipboard-injector (rm_SceneItem, rm_SceneLineItem, rm_Line) + +### Key learnings +- **QTimer does NOT work** in xovi QML singletons. The object created by + `qmlRegisterSingletonInstance` doesn't get timer events processed. Use + pthread + `QMetaObject::invokeMethod` instead. +- **`QCoreApplication::instance()` returns null** during `_xovi_construct`. + Don't check app type in constructor. Filter workers in entry.c using + `program_invocation_short_name`. +- **Xovi activation**: requires triple-tap (xovi-tripletap) or manual + `xovi/start` over SSH. Uses tmpfs systemd drop-in, lost on reboot. + `systemctl restart xochitl` alone preserves the tmpfs if already mounted. +- **Extension .so is loaded once** at xochitl startup. Updating the .so on + disk requires restarting xochitl to pick up changes. +- **Vtable cloning**: SceneLineItems need a valid vtable pointer cloned from + an existing xochitl-created item. Currently returns nil — needs fixing. +- **QQmlEngine access**: Can't find engine via `app->children()` iteration. + Need alternative approach for clipboard/scene injection. + +### Build & deploy workflow + +```sh +cd xovi-ext/grimoire-injector + +# First time: creates persistent Docker builder container +# Subsequent runs: incremental rebuild (~30s vs ~2min full build) +bash build.sh -- xochitl still occasionally crashes on reload despite correct `sizeInBytes`. - Need to investigate journalctl logs next time it happens. -- Hershey font subset is missing lowercase and numbers. M1 should add them. -- No whitespace awareness — text always renders at fixed position (M1). -- No trigger mechanism — just static injection (M2+). +# Then restart xochitl on device to load new .so: +ssh remarkable 'systemctl reset-failed xochitl && systemctl restart xochitl' +``` + +The `build.sh` script: +1. Creates/reuses a persistent `grimoire-builder` Docker container with the + `eeems/remarkable-toolchain:latest-rm2` image +2. Runs `qmake6 && make` inside the container (incremental compilation) +3. Copies the `.so` out and SCPs it to the device + +### JSON stroke format (matches clipboard-injector) + +```json +[{ + "points": [[x, y, speed, width, direction, pressure], ...], + "rgba": 4278190080, + "color": 0, + "bounds": [minX, minY, width, height], + "tool": 15, + "maskScale": 2.0, + "thickness": 2.0 +}] +``` + +Generate with: `python grimoire.py --json output.json "text"` + +### Remaining xovi work +- Fix vtable acquisition (hook QImage constructor like framebuffer-spy, or + find existing SceneLineItems in memory) +- Find QQmlEngine to access Clipboard context property +- Trigger paste programmatically after setting Clipboard.items +- Alternative: direct scene graph manipulation (deeper RE needed) + +## Script usage (`grimoire.py`) + +```sh +# File-based injection (creates new layer in .rm file) +python grimoire.py input.rm output.rm "reply text" + +# JSON export for xovi injector +python grimoire.py --json /tmp/grimoire_strokes.json "reply text" + +# Preview (renders SVG for visual tuning) +python grimoire.py --preview "text" +``` diff --git a/grimoire.py b/grimoire.py index e4053dd..7b151ab 100644 --- a/grimoire.py +++ b/grimoire.py @@ -271,12 +271,45 @@ def splice_reply_new_layer( f"{len(original_data)}+{len(reply_bytes)}={len(output_data)} bytes)") +def strokes_to_json(strokes: list[si.Line], output_path: str) -> None: + """Export strokes as JSON for the xovi grimoire-injector extension.""" + import json + items = [] + for line in strokes: + points = [] + for pt in line.points: + points.append([pt.x, pt.y, pt.speed, pt.width, pt.direction, pt.pressure]) + xs = [p[0] for p in points] + ys = [p[1] for p in points] + bounds = [min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)] + items.append({ + "points": points, + "rgba": 4278190080, # 0xFF000000 + "color": line.color.value if hasattr(line.color, 'value') else 0, + "bounds": bounds, + "tool": line.tool.value if hasattr(line.tool, 'value') else 15, + "maskScale": line.thickness_scale, + "thickness": line.thickness_scale, + }) + with open(output_path, "w") as f: + json.dump(items, f) + print(f"Wrote {output_path} ({len(items)} strokes)") + + if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("input") + parser.add_argument("input", nargs="?", default=None) parser.add_argument("output", nargs="?", default=None) parser.add_argument("text", nargs="?", default="Grimoire says hello") + parser.add_argument("--json", dest="json_output", default=None, + help="Export strokes as JSON for xovi injector") args = parser.parse_args() - output = args.output or args.input - splice_reply_new_layer(args.input, output, args.text) + if args.json_output: + strokes = text_to_strokes(args.text, DEFAULT_X, DEFAULT_Y) + strokes_to_json(strokes, args.json_output) + elif args.input: + output = args.output or args.input + splice_reply_new_layer(args.input, output, args.text) + else: + parser.error("Provide input.rm or --json output.json") diff --git a/xovi-ext/grimoire-injector/Dockerfile b/xovi-ext/grimoire-injector/Dockerfile new file mode 100644 index 0000000..2c1b355 --- /dev/null +++ b/xovi-ext/grimoire-injector/Dockerfile @@ -0,0 +1,10 @@ +FROM eeems/remarkable-toolchain:latest-rm2 + +WORKDIR /src +COPY . . + +RUN apt-get update && apt-get install -y git python3 && \ + git clone https://github.com/asivery/xovi /tmp/xovi && \ + bash -c "export XOVI_REPO=/tmp/xovi && . /opt/codex/*/*/environment-setup-* && \ + qmake6 && \ + make" diff --git a/xovi-ext/grimoire-injector/GrimoireInjector.cpp b/xovi-ext/grimoire-injector/GrimoireInjector.cpp new file mode 100644 index 0000000..12c2673 --- /dev/null +++ b/xovi-ext/grimoire-injector/GrimoireInjector.cpp @@ -0,0 +1,220 @@ +#include "GrimoireInjector.hpp" +#include "rm_SceneLineItem.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static GrimoireInjector *g_instance = nullptr; + +static void *watchThreadFunc(void *) { + fprintf(stderr, "[grimoire] Watch thread started\n"); + sleep(5); // Wait for xochitl to fully initialize + + const char *path = "/tmp/grimoire_strokes.json"; + long long lastMod = 0; + + while (true) { + struct stat st; + if (stat(path, &st) == 0 && st.st_mtime != lastMod) { + lastMod = st.st_mtime; + fprintf(stderr, "[grimoire] File changed (mtime=%lld), triggering load\n", + (long long)lastMod); + if (g_instance) { + // Call back into main thread + QMetaObject::invokeMethod(g_instance, "loadAndInject", Qt::QueuedConnection); + } + } + sleep(1); + } + return nullptr; +} + +GrimoireInjector::GrimoireInjector(QObject *parent) + : QObject(parent) +{ + g_instance = this; + // Spawn a real pthread for file watching — no Qt event loop dependency + pthread_t tid; + pthread_create(&tid, nullptr, watchThreadFunc, nullptr); + pthread_detach(tid); + fprintf(stderr, "[grimoire] Constructor done, watch thread spawned\n"); +} + +void GrimoireInjector::loadAndInject() { + fprintf(stderr, "[grimoire] loadAndInject called on main thread\n"); + int count = loadStrokes(m_watchPath); + if (count > 0) { + if (!m_vtableReady) setupVtable(); + injectToClipboard(); + } +} + +bool GrimoireInjector::injectToClipboard() { + if (m_items.empty()) { + fprintf(stderr, "[grimoire] injectToClipboard: no items\n"); + return false; + } + + // Find the QQmlEngine via the application's root objects + QQmlEngine *engine = nullptr; + auto *app = qobject_cast(QCoreApplication::instance()); + if (!app) { + fprintf(stderr, "[grimoire] No QGuiApplication\n"); + return false; + } + + // Try to find engine from top-level windows' QML engines + // The clipboard model is typically a context property on the root context + // We'll try to access it via the first available engine + QList engines; + + // Walk through all QObjects to find QQmlEngine + // Simpler: use QQmlEngine::contextForObject on any known QML object + // For now, try to find it via the global app properties + + // Alternative approach: directly manipulate the clipboard via + // xochitl's internal Clipboard model. The model is registered as + // a context property "Clipboard" in the root QML context. + // We need to find the engine first. + + // Try iterating over top-level objects to find a QML-created object + for (QObject *obj : app->children()) { + QQmlContext *ctx = QQmlEngine::contextForObject(obj); + if (ctx) { + engine = ctx->engine(); + break; + } + } + + if (!engine) { + // Fallback: try to get engine from any QQuickWindow + fprintf(stderr, "[grimoire] Could not find QQmlEngine from app children\n"); + // Try another approach - enumerate all objects + for (QObject *obj : QObjectList()) { + QQmlContext *ctx = QQmlEngine::contextForObject(obj); + if (ctx) { + engine = ctx->engine(); + break; + } + } + } + + if (!engine) { + fprintf(stderr, "[grimoire] No QQmlEngine found, cannot inject\n"); + return false; + } + + fprintf(stderr, "[grimoire] Found QQmlEngine at %p\n", (void*)engine); + + // Get root context and look for Clipboard + QQmlContext *rootCtx = engine->rootContext(); + if (!rootCtx) { + fprintf(stderr, "[grimoire] No root context\n"); + return false; + } + + QObject *clipboard = rootCtx->contextProperty("Clipboard").value(); + if (!clipboard) { + fprintf(stderr, "[grimoire] No Clipboard context property found\n"); + // List available context properties for debugging + fprintf(stderr, "[grimoire] Trying to find clipboard-like objects...\n"); + return false; + } + + fprintf(stderr, "[grimoire] Found Clipboard object at %p\n", (void*)clipboard); + + // Set items on the clipboard model + // The clipboard model has an 'items' property that accepts QList> + QVariant itemsVariant = QVariant::fromValue(m_items); + bool ok = clipboard->setProperty("items", itemsVariant); + fprintf(stderr, "[grimoire] Set Clipboard.items: %s (%d items)\n", + ok ? "ok" : "FAILED", m_items.size()); + + return ok; +} + +int GrimoireInjector::loadStrokes(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + fprintf(stderr, "[grimoire] Cannot open %s\n", path.toUtf8().constData()); + return 0; + } + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + QJsonArray jsonArray = doc.array(); + + fprintf(stderr, "[grimoire] Loading %d strokes from %s\n", + jsonArray.size(), path.toUtf8().constData()); + + m_items.clear(); + + for (int i = 0; i < jsonArray.size(); i++) { + QJsonObject obj = jsonArray[i].toObject(); + QJsonArray pointsArray = obj["points"].toArray(); + QList linePoints; + + float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f; + + for (int j = 0; j < pointsArray.size(); j++) { + QJsonArray ptArr = pointsArray[j].toArray(); + LinePoint pt; + pt.x = static_cast(ptArr[0].toDouble()); + pt.y = static_cast(ptArr[1].toDouble()); + pt.speed = static_cast(ptArr[2].toInt()); + pt.width = static_cast(ptArr[3].toInt()); + pt.direction = static_cast(ptArr[4].toInt()); + pt.pressure = static_cast(ptArr[5].toInt()); + linePoints.append(pt); + + minX = std::min(minX, pt.x); + minY = std::min(minY, pt.y); + maxX = std::max(maxX, pt.x); + maxY = std::max(maxY, pt.y); + } + + QRectF bounds(minX, minY, maxX - minX, maxY - minY); + + Line line; + line.points = linePoints; + line.bounds = bounds; + line.rgba = static_cast(obj["rgba"].toDouble(0xFF000000)); + line.color = obj["color"].toInt(0); + line.tool = obj["tool"].toInt(0x0F); // BALLPOINT_2 + line.maskScale = obj["maskScale"].toDouble(1.0); + line.thickness = static_cast(obj["thickness"].toDouble(2.0)); + + auto item = std::make_shared( + SceneLineItem::fromLine(std::move(line))); + m_items.push_back(item); + } + + fprintf(stderr, "[grimoire] Loaded %d scene items\n", m_items.size()); + return m_items.size(); +} + +bool GrimoireInjector::setupVtable() { + if (m_items.empty()) { + fprintf(stderr, "[grimoire] setupVtable: no items loaded\n"); + return false; + } + auto* item = reinterpret_cast(m_items.first().get()); + SceneLineItem::setupVtable(item->vtable); + m_vtableReady = true; + fprintf(stderr, "[grimoire] Vtable ready\n"); + return true; +} diff --git a/xovi-ext/grimoire-injector/GrimoireInjector.hpp b/xovi-ext/grimoire-injector/GrimoireInjector.hpp new file mode 100644 index 0000000..80db854 --- /dev/null +++ b/xovi-ext/grimoire-injector/GrimoireInjector.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include +#include "rm_SceneItem.hpp" +#include "rm_Line.hpp" + +class GrimoireInjector : public QObject { + Q_OBJECT +public: + explicit GrimoireInjector(QObject *parent = nullptr); + + Q_INVOKABLE int loadStrokes(const QString& path); + Q_INVOKABLE bool setupVtable(); + Q_INVOKABLE int itemCount() const { return m_items.size(); } + Q_INVOKABLE bool isReady() const { return m_vtableReady; } + Q_INVOKABLE bool injectToClipboard(); + +public slots: + void loadAndInject(); + +private: + QList> m_items; + bool m_vtableReady = false; + QString m_watchPath = "/tmp/grimoire_strokes.json"; + qint64 m_lastModTime = 0; +}; diff --git a/xovi-ext/grimoire-injector/build.sh b/xovi-ext/grimoire-injector/build.sh new file mode 100755 index 0000000..43e401a --- /dev/null +++ b/xovi-ext/grimoire-injector/build.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Fast rebuild: reuses container, only recompiles changed files +set -e +cd "$(dirname "$0")" + +CONTAINER="grimoire-builder" + +# Create persistent builder container if needed +if ! docker inspect $CONTAINER &>/dev/null; then + echo "Creating builder container..." + docker create --platform linux/amd64 --name $CONTAINER \ + -v "$(pwd):/src" \ + -w /src \ + eeems/remarkable-toolchain:latest-rm2 \ + bash -c "git clone https://github.com/asivery/xovi /tmp/xovi 2>/dev/null; tail -f /dev/null" + docker start $CONTAINER + sleep 3 +fi + +echo "Building..." +docker exec $CONTAINER bash -c ' +export XOVI_REPO=/tmp/xovi +. /opt/codex/*/*/environment-setup-* +qmake6 2>/dev/null +make 2>&1 +' + +echo "Copying .so..." +docker cp $CONTAINER:/src/grimoire-injector.so . + +echo "Deploying to device..." +scp grimoire-injector.so remarkable:/home/root/xovi/extensions.d/ + +echo "Done! Triple-tap to reload." diff --git a/xovi-ext/grimoire-injector/entry.c b/xovi-ext/grimoire-injector/entry.c new file mode 100644 index 0000000..0fc0ed0 --- /dev/null +++ b/xovi-ext/grimoire-injector/entry.c @@ -0,0 +1,20 @@ +#define _GNU_SOURCE +#include +#include +#include "xovi.h" + +void registerGrimoire(); +extern char *program_invocation_short_name; + +static int is_worker = 0; + +int isGrimoireWorker() { return is_worker; } + +void _xovi_construct() { + if (strstr(program_invocation_short_name, "worker") != NULL) { + is_worker = 1; + return; + } + printf("[grimoire] Main process (%s), registering\n", program_invocation_short_name); + registerGrimoire(); +} diff --git a/xovi-ext/grimoire-injector/grimoire-injector.pro b/xovi-ext/grimoire-injector/grimoire-injector.pro new file mode 100644 index 0000000..e1d725a --- /dev/null +++ b/xovi-ext/grimoire-injector/grimoire-injector.pro @@ -0,0 +1,31 @@ +TEMPLATE = lib +TARGET = grimoire-injector +CONFIG += shared plugin no_plugin_name_prefix +QMAKE_LFLAGS += -Wl,--no-undefined + +OBJECTS_DIR = build/obj +MOC_DIR = build/moc +UI_DIR = build/ui +XOVI_DIR = build/xovi + +xoviextension.target = build/xovi/xovi.c +xoviextension.commands = mkdir -p $$XOVI_DIR && python3 $$(XOVI_REPO)/util/xovigen.py -o $$XOVI_DIR/xovi.c -H $$XOVI_DIR/xovi.h grimoire-injector.xovi +xoviextension.depends = grimoire-injector.xovi + +QMAKE_EXTRA_TARGETS += xoviextension +PRE_TARGETDEPS += $$XOVI_DIR/xovi.c + +QT += quick qml + +CONFIG += c++17 + +SOURCES += \ + main.cpp entry.c $$XOVI_DIR/xovi.c \ + GrimoireInjector.cpp \ + rm_Line.cpp rm_SceneLineItem.cpp + +HEADERS += GrimoireInjector.hpp rm_Line.hpp rm_SceneItem.hpp rm_SceneLineItem.hpp +INCLUDEPATH += $$XOVI_DIR + +QMAKE_CXXFLAGS += -fPIC -Werror -Wno-invalid-offsetof -O3 -mfpu=neon -mfloat-abi=hard +QMAKE_CFLAGS += -fPIC -O3 -mfpu=neon -mfloat-abi=hard diff --git a/xovi-ext/grimoire-injector/grimoire-injector.xovi b/xovi-ext/grimoire-injector/grimoire-injector.xovi new file mode 100644 index 0000000..e5d5119 --- /dev/null +++ b/xovi-ext/grimoire-injector/grimoire-injector.xovi @@ -0,0 +1 @@ +version 0.1.0 diff --git a/xovi-ext/grimoire-injector/main.cpp b/xovi-ext/grimoire-injector/main.cpp new file mode 100644 index 0000000..86981fc --- /dev/null +++ b/xovi-ext/grimoire-injector/main.cpp @@ -0,0 +1,8 @@ +#include +#include "GrimoireInjector.hpp" + +extern "C" void registerGrimoire() { + qmlRegisterSingletonInstance( + "dev.grimoire.injector", 0, 1, + "GrimoireInjector", new GrimoireInjector()); +} diff --git a/xovi-ext/grimoire-injector/rm_Line.cpp b/xovi-ext/grimoire-injector/rm_Line.cpp new file mode 100644 index 0000000..f22445c --- /dev/null +++ b/xovi-ext/grimoire-injector/rm_Line.cpp @@ -0,0 +1,13 @@ +#include "rm_Line.hpp" + +Line Line::fromPoints(QList &&points, const QRectF& bounds) { + Line line = {}; + line.tool = 0x0F; // BALLPOINT_2 (from rmscene) + line.color = 0; // Black + line.rgba = 0xff000000; + line.points = QList(points.begin(), points.end()); + line.maskScale = 1.0; + line.thickness = 2.0f; + line.bounds = bounds; + return line; +} diff --git a/xovi-ext/grimoire-injector/rm_Line.hpp b/xovi-ext/grimoire-injector/rm_Line.hpp new file mode 100644 index 0000000..e8c76f9 --- /dev/null +++ b/xovi-ext/grimoire-injector/rm_Line.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +struct LinePoint { + float x; + float y; + unsigned short speed; + unsigned short width; + unsigned char direction; + unsigned char pressure; +} __attribute__((packed)); +static_assert(sizeof(LinePoint) == 0xe, "LinePoint size mismatch"); + +struct Line { + int tool; + int color; + unsigned int rgba; + + QList points; + + double maskScale; + float thickness; + + QRectF bounds; + + static Line fromPoints(QList &&points, const QRectF& bounds); +}; diff --git a/xovi-ext/grimoire-injector/rm_SceneItem.hpp b/xovi-ext/grimoire-injector/rm_SceneItem.hpp new file mode 100644 index 0000000..1d44f59 --- /dev/null +++ b/xovi-ext/grimoire-injector/rm_SceneItem.hpp @@ -0,0 +1,5 @@ +#pragma once + +struct SceneItem { + void* vtable; +}; diff --git a/xovi-ext/grimoire-injector/rm_SceneLineItem.cpp b/xovi-ext/grimoire-injector/rm_SceneLineItem.cpp new file mode 100644 index 0000000..f2e09f3 --- /dev/null +++ b/xovi-ext/grimoire-injector/rm_SceneLineItem.cpp @@ -0,0 +1,19 @@ +#include "rm_SceneLineItem.hpp" +#include + +void* SceneLineItem::vtable_ptr = nullptr; + +void SceneLineItem::setupVtable(void* vtable) { + printf("[grimoire] Setting SceneLineItem vtable to %p\n", vtable); + SceneLineItem::vtable_ptr = vtable; +} + +SceneLineItem* SceneLineItem::tryCast(SceneItem* item) { + if (!item) return nullptr; + auto* lineItem = reinterpret_cast(item); + if (lineItem->unk_xc != 0) return nullptr; + if (lineItem->unk_xe != 0) return nullptr; + if (lineItem->unk_x78 != 1) return nullptr; + if (!(lineItem->unk_x20 == 0x0 || (lineItem->unk_x20 == 0x2 && lineItem->unk_x21 == 0x2))) return nullptr; + return lineItem; +} diff --git a/xovi-ext/grimoire-injector/rm_SceneLineItem.hpp b/xovi-ext/grimoire-injector/rm_SceneLineItem.hpp new file mode 100644 index 0000000..3126bbc --- /dev/null +++ b/xovi-ext/grimoire-injector/rm_SceneLineItem.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include "rm_Line.hpp" +#include "rm_SceneItem.hpp" + +struct SceneLineItem : public SceneItem { + unsigned char unk_x4; + int pageIndex; + short unk_xc; + short unk_xe; + int sourceLayerId; + short unk_x14; + short unk_x16; + int unk_x18; + void* unk_x1c; + unsigned char unk_x20; + unsigned char unk_x21; + void* unk_x24[3]; + Line line; + int unk_x78; + int unk_x7c[3]; + + static void* vtable_ptr; + static void setupVtable(void* vtable); + + static SceneLineItem fromLine(Line &&line, int layerId = 0xB) { + SceneLineItem item = {}; + item.vtable = vtable_ptr; + item.unk_x4 = 3; + item.pageIndex = 0xE; + item.unk_xe = 1; + item.sourceLayerId = layerId; + item.line = line; + item.unk_x78 = 1; + return item; + } + + static SceneLineItem* tryCast(SceneItem* item); +};