Compare commits

...

8 Commits

Author SHA1 Message Date
funman300 0247efcb07 Merge pull request 'perf(engine): rebuild card children only on appearance change' (#112) from fix/card-move-anim-jank into master
Build and Deploy / build-and-push (push) Successful in 2m1s
Web WASM Rebuild / rebuild (push) Successful in 6m40s
2026-06-26 19:03:19 +00:00
funman300 1d5266a811 perf(engine): rebuild card children only on appearance change
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).

Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.

Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:02:42 -07:00
funman300 26283b5478 Merge pull request 'fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)' (#111) from fix/apk-signing-v1-obtainium into master
Android Release / build-apk (push) Successful in 5m15s
2026-06-25 18:16:57 +00:00
funman300 3627e9f9cf fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)
The apksigner step relied on auto scheme selection, which produced an APK
carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were
present but failed v1 verification (apksigner reports `v1 scheme: false`
while v2/v3 verify). Android installs such an APK fine via v2/v3, but
Obtainium parses the legacy v1 certificate at install time, gets an empty
cert list, and crashes with:

  RangeError (length): Invalid value: valid value range is empty: 0

This is why the app adds fine in Obtainium (Gitea API only) but fails on
install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary —
sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24)
and pass --min-sdk-version 26. Adds a post-sign guard that fails the build
if any META-INF v1 signature files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:16:38 -07:00
funman300 b81a79c51c Merge pull request 'docs(handoff): mark physical-device smoke test as the only v0.40.0 item left' (#110) from docs/handoff-only-smoke-test into master 2026-06-25 17:48:31 +00:00
funman300 968721eeb4 docs(handoff): mark physical-device smoke test as the only v0.40.0 item left
Elevates the physical-device smoke test as the single remaining task for the
v0.40.0 release and clarifies the Matomo validation is an independent task,
not a release blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:48:16 -07:00
funman300 780e82ca4b Merge pull request 'docs(handoff): record v0.40.0 release' (#109) from docs/handoff-v0.40.0 into master 2026-06-25 17:46:06 +00:00
funman300 207747db4b docs(handoff): record v0.40.0 release
Updates SESSION_HANDOFF.md with the v0.40.0 Android release (PRs #105/#106/#108),
the pre-release validation performed, and the still-open physical-device smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:45:49 -07:00
3 changed files with 217 additions and 60 deletions
+52 -17
View File
@@ -1,16 +1,40 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-09AVD Android launch smoke passed; physical-device gate remains.
**Last updated:** 2026-06-25v0.40.0 released (Android APK published); physical-device gate remains.
---
## Current state
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
- **Latest tag:** `v0.39.0`
- **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed.
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke.
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
---
## v0.40.0 release (2026-06-25)
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
pick it up automatically.
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
| PR | Summary |
|----|---------|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264``30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
---
@@ -103,12 +127,23 @@ Three bugs fixed:
## Open punch list
### 1. Android APK launch verification (Option A)
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
Physical device test: install the latest APK on a real Android device (not AVD),
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI.
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device
smoke testing is the only gate.
This is the **single outstanding task** for the v0.40.0 Android release. Everything
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
clippy, release manifest sanity, a full local signed APK, the published release, and
Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
@@ -117,13 +152,13 @@ rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
validation against the deployed Matomo instance. Use
`docs/analytics-validation.md` for the native validation checklist and the
current web/WASM decision notes.
Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
validation checklist and the current web/WASM decision notes.
---
+21 -1
View File
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT"
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
# certificate at install time, gets an empty cert list, and crashes with
# "RangeError (length): Invalid value: valid value range is empty: 0".
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
# modern Android tooling produces for minSdk >= 24.
"$BT/apksigner" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \
--min-sdk-version 26 \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled true \
--out "$APK_OUT" \
"$STAGING/app-aligned.apk"
echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT"
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
# not) is what tripped Obtainium. Fail the build if any slipped through.
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
exit 1
fi
echo ">>> done: $APK_OUT"
+144 -42
View File
@@ -195,6 +195,40 @@ pub struct CardEntity {
pub card: Card,
}
/// Cached signature of the inputs that determine a card entity's *child*
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
/// the appearance changed — the common case during a move, where only the
/// card's position changes. Without this guard every `StateChangedEvent`
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
/// glyph re-layouts) in a single frame, which stutters the slide animation on
/// high-resolution devices.
///
/// The children depend only on these four inputs; the card identity is fixed
/// per entity, and the face/back *image* is handled by the always-refreshed
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
/// rebuild.
#[derive(Component, Clone, Copy, PartialEq)]
struct CardChildrenKey {
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
}
/// Query data read by the card-sync systems for each live card entity:
/// its id, card identity, current transform, any in-flight curve animation,
/// and its cached child-appearance key. Factored into an alias to keep the
/// system signatures readable (and satisfy clippy's `type_complexity`).
type CardSyncData = (
Entity,
&'static CardEntity,
&'static Transform,
Option<&'static CardAnimation>,
Option<&'static CardChildrenKey>,
);
/// Render-side index mapping each live board card to its [`CardEntity`].
///
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
@@ -732,7 +766,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
@@ -767,7 +801,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
@@ -806,7 +840,7 @@ fn sync_cards(
back_colour: Color,
color_blind: bool,
high_contrast: bool,
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: &Query<CardSyncData>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
@@ -840,18 +874,24 @@ fn sync_cards(
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
HashMap::new();
for (entity, marker, transform, anim, children_key) in entities.iter() {
existing.insert(
marker.card.clone(),
(entity, transform.translation, anim.map(|a| a.end)),
(
entity,
transform.translation,
anim.map(|a| a.end),
children_key.copied(),
),
);
}
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked.
for (card, (entity, _, _)) in &existing {
for (card, (entity, _, _, _)) in &existing {
if !live_ids.contains(card) {
commands.entity(*entity).despawn();
}
@@ -862,7 +902,7 @@ fn sync_cards(
// behind the incoming top card during the draw slide animation.
for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end)) => {
Some(&(entity, cur, anim_end, children_key)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter),
@@ -889,6 +929,7 @@ fn sync_cards(
high_contrast,
cur,
has_anim,
children_key,
card_images,
selected_back,
font_handle,
@@ -1114,6 +1155,14 @@ fn spawn_card_entity(
);
});
}
// Record the appearance signature so subsequent `update_card_entity` calls
// can skip rebuilding these children until one of the inputs changes.
entity.insert(CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
});
entity_id
}
@@ -1132,6 +1181,7 @@ fn update_card_entity(
high_contrast: bool,
cur: Vec3,
has_card_animation: bool,
existing_children_key: Option<CardChildrenKey>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
@@ -1180,44 +1230,58 @@ fn update_card_entity(
}
}
// Despawn any stale children and re-add the per-card drop shadow plus,
// in solid-colour fallback mode, the label overlay. In image mode the
// rank/suit are baked into the PNG; on Android we also add a large-print
// corner overlay so they are legible at phone scale.
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
// Rebuild the card's child visuals (drop-shadow, border frame, and the
// rank/suit label / large-print corner overlay) only when an input that
// affects them actually changed. The child set depends solely on
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
// this on a position-only move avoids despawning and respawning the child
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
// `StateChangedEvent` — the spike that stuttered the slide animation on
// high-resolution devices.
let new_children_key = CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
};
if existing_children_key != Some(new_children_key) {
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
add_card_shadow_child(b, layout.card_size);
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
commands.entity(entity).insert(new_children_key);
}
}
@@ -3216,6 +3280,44 @@ mod tests {
}
}
#[test]
fn appearance_neutral_state_change_does_not_rebuild_card_children() {
// A StateChangedEvent that doesn't alter any card's appearance — the
// common case during a move, for the ~50 cards that didn't move or flip
// — must NOT despawn and respawn child entities. Before the
// CardChildrenKey guard every StateChangedEvent rebuilt all 52 cards'
// children (incl. a Text2d glyph re-layout each), the per-move spike
// that stuttered the slide animation on high-resolution devices.
let mut app = app();
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback path"
);
// Fire a StateChangedEvent without mutating the game: no card moves or
// flips, so every card's CardChildrenKey is unchanged.
app.world_mut().write_message(StateChangedEvent);
app.update();
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert_eq!(
labels_before, labels_after,
"an appearance-neutral StateChangedEvent must not despawn/respawn card \
children — the CardChildrenKey guard should have skipped the rebuild"
);
}
#[test]
fn resize_in_place_updates_card_label_font_size() {
// Capture an arbitrary CardLabel's TextFont.font_size before resize,