Muzzlemeter is an unofficial iOS companion app for the Acetech AC6000 BT airsoft
chronograph. It connects over Bluetooth LE, shows each shot's velocity and energy live,
and keeps a searchable history per gun profile. Built with SwiftUI, SwiftData and
CoreBluetooth. The BLE protocol is not published by the vendor, so it was worked out from
packet captures of my own device and verified against the unit's own LCD; the result is
documented in docs/PROTOCOL.md. Not affiliated with Acetech. MIT
licensed.
This app is unofficial and unaffiliated with Acetech. Acetech and AC6000 are trademarks of their respective owners.
| Device | Status |
|---|---|
| AC6000 MKIII BT | Confirmed on real hardware (verified against the unit's own LCD) |
| AC6000 BT | Should work (matched by a prefix match on the advertised name AC6000BT-; same frame format) |
| Other Acetech products (e.g. Bifrost) | Not supported (the protocol is very likely different) |
- Automatically connects to the device over BLE (remembers the last-connected unit and reconnects to it)
- Live display of each shot's velocity (m/s / fps) and energy (J)
- Session statistics: mean / max / min / SD / ES / rate of fire
- Gun profiles (manufacturer, model, power-source category, inner barrel length, regulation limit in J, default BB weight, etc.) and per-session variables (BB weight, gas type, hop setting)
- Records temperature, humidity, and pressure at the time of measurement, fetched once from WeatherKit (can also be overridden by hand)
- Browse, rename, and export history to CSV
- Session comparison (side-by-side stats for 2-3 sessions, overlaid velocity charts, a spread summary)
- Tagging, and filtering history by tag, gun, or free text
- Per-profile trends (a time series of mean velocity ± SD, and a scatter plot against temperature)
- English / Japanese / Traditional Chinese (zh-Hant). English is the fallback for any other locale; the source string literals are Japanese and act as the catalog keys
- Demo mode: anyone can turn it on from Settings (or from the Live screen the first time, before any chronograph has ever connected) and watch a synthetic airsoft session run through the whole app without owning a chronograph. It is marked DEMO everywhere it appears, and demo sessions can be deleted in one action
- A replay mode for development that lets you review the UI without the physical device (plays back a real capture)
- Importing the log stored on the device itself (
0x62/0x63, confirmed on real hardware. The log is volatile, so the app remembers how far it read into each device's log last time and reads only the difference) - A macOS CLI for protocol analysis,
muzzlemeter-sniff(BLE scanning / GATT enumeration / packet dumping)
See docs/PROTOCOL.md for the full detail. Here's the minimum an implementer needs:
| Scan | prefix match on the advertised name AC6000BT-. No service is advertised, so scan with services: nil |
| notify | 3337E46E-F79E-4FF5-9A49-77C36D170C62 (service 5CDE0C3D-...) |
| write | 9C6AA1EE-B4B9-44A1-BA45-1558C9109B4C (service 53C47FE1-...) Write With Response |
| Frame | AA <L> <cmd> <payload...> <cks>. L is the total frame length |
| Checksum | (Σ frame[0..L-2] + key1 + key2) & 0xFF. You can neither build nor verify a frame without the key |
| Key | bytes 4 and 5 of the advertisement's manufacturer data (00 05 08 c4 94 52 04 → key1=0xC4 key2=0x94) |
| Handshake | TX aa 06 4b <k1> <k2> <cks> (signed with key 0/0, this one frame only) → RX aa 05 41 4b <cks> (ACK). Returns in 55 ms. No button press needed on the device |
| Keep-alive | not needed (don't send one) |
| Speed | FIRE_REPORT (0x52)'s rawSpeed / 100 = m/s (confirmed against the device's own LCD). The LCD truncates, so the app's m/s display truncates the same way |
| Power off | a 1-byte 00 notification. Not an error. The link drops ~0.76 s later |
| Device log | 0x62 (count) / 0x63 (one record, 1-byte 1-based index). Confirmed against real hardware. The log is volatile — it resets to 0 records on power-cycle |
| 🚫 Forbidden | writing to the OTA characteristic F7BF3564-... (bricking risk). Sending 0x61 CLEAR_LOG |
The device also sends frames you never asked for (0x47 / 0x5A periodically,
roughly every 10 s after connecting). Don't treat "unexpected" as an error.
| Path | Contents |
|---|---|
Sources/MuzzlemeterKit/ |
Domain / protocol / BLE abstractions (a library shared by iOS and macOS) |
Sources/MuzzlemeterSniff/ |
The macOS CLI: BLE scanning / GATT enumeration / packet dumping |
Tests/MuzzlemeterKitTests/ |
Tests written with Swift Testing |
Tests/MuzzlemeterAppModelsTests/ |
Tests for the app's SwiftData models (demo-session flagging and cleanup) against an in-memory store. Package.swift compiles App/Muzzlemeter/Models/ a second time as the MuzzlemeterAppModels target purely so swift test can reach it; the app target links no such library and just compiles the same files itself |
docs/PROTOCOL.md |
The reverse-engineering results (UUIDs, packet format, checksum) |
tools/re/ |
The working directory for protocol research; capture log storage (gitignored) |
App/Muzzlemeter/ |
The iOS app (SwiftUI + SwiftData) |
App/Muzzlemeter/Resources/Localizable.xcstrings |
The String Catalog for UI text. Its sourceLanguage is ja (the Swift literals are the keys); en, ja and zh-Hant are all complete translations. CFBundleDevelopmentRegion is en, so any locale the app does not ship falls back to English |
App/Info.plist |
A minimal base holding only CFBundleLocalizations; every other key is synthesized from build settings |
App/Muzzlemeter/Resources/<lang>.lproj/InfoPlist.strings |
en / ja / zh-Hant purpose strings for permission prompts, substituting build-setting values at runtime |
App/Muzzlemeter.entitlements |
The WeatherKit entitlement (CODE_SIGN_ENTITLEMENTS) |
project.yml |
The XcodeGen definition that .xcodeproj is generated from |
Lists nearby BLE advertisements. Run it with the device powered on to check its name.
swift run muzzlemeter-sniff scan swift run muzzlemeter-sniff scan --seconds 30 swift run muzzlemeter-sniff scan --filter-service ffe0
Example output (one device per line, deduplicated by identifier):
name: AC6000 id: 1A2B3C4D-... rssi: -54 services: [FFE0] mfg: 4c 00 12 ...
Connects, enumerates every service / characteristic, reads each readable one once, subscribes to every notify / indicate, and streams incoming packets as timestamped hex. Ctrl-C to quit. Automatically tries to reconnect if disconnected.
swift run muzzlemeter-sniff dump --name AC6000
swift run muzzlemeter-sniff dump --id 1A2B3C4D-5E6F-... # identifier from scan output
swift run muzzlemeter-sniff dump --name AC6000 --log tools/re/captures/single-shot.logThe format of a packet line:
[2026年09月02日T22:31:04.512+09:00] [+412.7 ms] FFE1 len=8 hex: aa 55 01 5a ... ascii: .U.Z....
Unless given explicitly, the log is saved automatically to
tools/re/captures/<yyyyMMdd-HHmmss>.log.
Extracts the key from the advertisement's manufacturer data and automatically sends
0x4B READ_KEY once subscription completes. The decoded meaning of each received frame
is shown on the line right after its hex dump.
swift run muzzlemeter-sniff dump --name AC6000BT- --handshake
Expected output:
見つかりました: name: AC6000BT-009809 ... mfg: 00 05 08 c4 94 52 04
handshake: 広告から鍵を取得しました key1=0xc4 key2=0x94
[2026年09月03日T...] write -> 9C6AA1EE-... (withResponse) len=6 hex: aa 06 4b c4 94 53
-> READ_KEY key1=0xc4 key2=0x94
write 成功 9C6AA1EE-...
[2026年09月03日T...] [+55.0 ms] 3337E46E-... len=5 hex: aa 05 41 4b 93 ascii: ..AK.
-> ACK for READ_KEY ← success looks like this
Firing a BB from here decodes as a FIRE_REPORT:
[+...] 3337E46E-... len=10 hex: aa 0a 52 00 00 45 01 00 00 a4
-> FIRE_REPORT rawSpeed=325 (3.25 m/s) rawRev=0 flags=0
--handshake and --interactive can be combined (to poke around further by hand after
the handshake):
swift run muzzlemeter-sniff dump --name AC6000BT- --handshake --interactive
Frame building and validation reuse MuzzlemeterKit's own ChronoFrame directly, so
any byte sequence that gets through the sniffer will also get through the app.
The device accumulates measurement results internally. The record count (0x62) has
been confirmed by measurement, but 0x63, which reads records one at a time, has never
once been observed — neither the shape of the request nor of the response
(docs/PROTOCOL.md §6.6). --read-log sends the guessed request (payload = the LE16
index) in index order and writes out the raw response as-is.
swift run muzzlemeter-sniff dump --name AC6000BT- --read-log
--read-log sends keyed frames, so it automatically enables --handshake (so no one
gets stuck wondering why nothing responds because they forgot the flag).
Expected output:
=== --read-log: 本体内ログを読み出します(0x62 → 0x63。0x61 は送りません) ===
[...] write -> 9C6AA1EE-... (withResponse) len=5 hex: aa 05 62 00 69
[...] 3337E46E-... len=6 hex: aa 06 62 00 01 6b
-> LOG_COUNT count=1(status=0)
read-log: 本体内ログ 1 件
[...] write -> 9C6AA1EE-... (withResponse) len=6 hex: aa 06 63 00 00 6a
[...] 3337E46E-... len=10 hex: aa 0a 63 00 00 1a 01 00 00 8a
read-log: record 0/1 rawSpeed=282 (2.82 m/s) rawRev=0 payload: 00 00 1a 01 00 00
=== read-log: 採取した 0x63 の payload(1 行 = 1 レコード: <index> <hex>) ===
0 00 00 1a 01 00 00
=== ここまで。この部分をそのまま共有してください ===
This output (or the log file) is exactly the material that will settle what 0x63
actually is. Any of the following outcomes would be useful:
- It reads as
record N/M rawSpeed=...→ matches the guessed layout, the same asFIRE_REPORT 未知の形式 payload: ...("unknown format, payload: ...") → a different layout; work it out from the raw payload0x63 index=0 に応答がありませんでした("no response to 0x63 index=0") → the request shape is wrong; try a different shape (e.g.[0x01, index]) with--interactive
🚫
0x61(CLEAR_LOG) is never sent. No builder for it exists at all, so it can't be sent unless someone hand-assembles the hex themselves (docs/PROTOCOL.md§11).
Used when the device requires a "start measurement" command (sends whatever byte
sequence you want to try, explicitly). Sent once subscription completes, with the result
shown. --write can be given multiple times.
swift run muzzlemeter-sniff dump --name AC6000 --write ffe1 01a50000 --write ffe1 02
swift run muzzlemeter-sniff dump --name AC6000 --write ffe1=01a50000 # "=" separator also worksWhen --write is given multiple times, they're sent one at a time, 200 ms apart by
default — so a response can be matched to the write that caused it. The interval can be
changed with --write-delay <ms> (0 sends them back-to-back).
swift run muzzlemeter-sniff dump --name AC6000 --write ffe1 01 --write ffe1 02 --write-delay 500
A BLE write can be withResponse (the peer returns an ATT response) or withoutResponse (fire-and-forget). Which one a device accepts depends on its firmware — some only process one of the two, and some declare one property but actually behave differently. Because of that, the type can be chosen explicitly.
| Value | Behavior |
|---|---|
auto (default) |
withResponse if the write property is present, otherwise withoutResponse |
with |
Always withResponse, regardless of the property |
without |
Always withoutResponse, regardless of the property |
with / without force the write even to a characteristic that doesn't declare the
property (with a warning line). The whole point of this option is being able to try a
peer whose declared properties can't be trusted.
swift run muzzlemeter-sniff dump --name AC6000 --write-type without --write ffe1 850600
--write-type becomes the default for --write and for <hex> / w in interactive
mode. In interactive mode it can be overridden per-command with wr / wn.
Adding --interactive (short form -i) makes it read commands from stdin one line at
a time once GATT enumeration, subscription, and any --writes have finished. This
removes the need to reconnect for every byte sequence you want to try, so it's the tool
of choice when brute-forcing an unknown handshake.
swift run muzzlemeter-sniff dump --name AC6000 --interactive swift run muzzlemeter-sniff dump --name AC6000 -i --write ffe1 01a50000 # initialize, then interact swift run muzzlemeter-sniff dump --name AC6000 -i --write-type without # default to without
Accepted commands:
| Input | Action |
|---|---|
5a 4b 00 4b / 5a4b004b |
Send to the default write characteristic (type follows --write-type) |
w <char> <hex> |
Write to a specified characteristic (type follows --write-type) |
wr [<char>] <hex> |
Write with response |
wn [<char>] <hex> |
Write without response |
r <char> |
Read a characteristic |
sub <char> / unsub <char> |
Subscribe to / unsubscribe from notify |
mtu |
Show the max bytes sendable in one write |
list |
Redisplay the GATT tree (with *notifying* markers) |
h |
Help |
q / Ctrl-D |
Disconnect and quit |
-
<char>can be a characteristic UUID (a short form likeffe1also works), or a prefix match that resolves uniquely. If it matches more than one, the candidates are printed and nothing is sent. -
The default write characteristic is the first characteristic with write (with response); if none, the first with writeWithoutResponse. It's shown at startup together with the default write type.
-
<char>can be omitted forwr/wn. It's interpreted as a characteristic only when there are 2 or more tokens and the first one is 4, 8, or 36 digits long. Inwn 85 06 4b,85is only 2 digits, so it stays hex. To send 4-digit-grouped hex to the default target, join it into one token:wn 85064b00. -
Multiple frames can be written on one line, separated by
;. They're sent in order spaced--write-delayapart, so a sequence that needs to be sent back-to-back (like a handshake) can be tried on a single line:> 85 06 4b 00 00 d6 ; 85 05 5a 00 e4 > wn 8506 ; wr ffe1 5a00 ; r ffe1If even one segment can't be parsed, nothing is sent at all (to prevent a typo from sending only the first half).
-
Blank lines and lines starting with
#are ignored. Unparseable input prints a one-line usage message. -
Every write / read / result during the session is recorded in the capture log too, so it can be replayed later with
ReplayScript.
maximumWriteValueLength, shown by mtu (and automatically right after connecting), is
useful for telling apart "the frame is too long and getting cut off" from "it's not
arriving at all." withoutResponse is typically the negotiated ATT_MTU minus 3 bytes;
withResponse is usually reported as up to 512, since it's split into multiple packets.
最大 write 長: withResponse=512 bytes withoutResponse=182 bytes
Since notify keeps streaming in asynchronously even while typing, the > prompt is only
shown right after startup and right after each command's result (showing it every
time would flood the screen with packets).
Candidate byte sequences can also be piped or redirected in from a file. When reading
from something other than a terminal, lines are processed one at a time, --write-delay
apart, so it's still clear which response belongs to which line:
printf '5a4b004b\n5a4b014c\n' | swift run muzzlemeter-sniff dump --name AC6000 -i swift run muzzlemeter-sniff dump --name AC6000 -i --write-delay 500 < candidates.txt
Using CoreBluetooth from the CLI requires Bluetooth permission for the terminal app itself. A permission dialog appears the first time it runs. If it doesn't appear, or you accidentally denied it, go to
System Settings > Privacy & Security > Bluetooth
turn on Terminal.app (or iTerm.app, or whichever app you're running it from), and restart that app before running it again.
Important notes:
- Permission is evaluated against the parent app that launched the process (the
responsible process), not
muzzlemeter-sniffitself. Launching it from an editor's or another tool's integrated terminal means that app lacks permission, and macOS kills the process outright with SIGABRT (in that case, a message explaining why is printed to stderr before exiting). Run it directly from Terminal.app / iTerm.app. - The CLI executable embeds an Info.plist containing
NSBluetoothAlwaysUsageDescriptionin its__TEXT,__info_plistsection (viaPackage.swift's linker settings andSources/MuzzlemeterSniff/Info.plist). Without it, CoreBluetooth initialization always crashes. - If Bluetooth is off, or permission has been denied, this prints that fact and exits with status code 1.
Works with just the Command Line Tools toolchain (Swift 6.4).
swift build
swift test.xcodeproj is a build artifact generated from project.yml via XcodeGen, and is
not included in the repository (gitignored). Always run this first after cloning, or
after changing project.yml or the file layout:
xcodegen generate # -> Muzzlemeter.xcodeprojBuilding the iOS app requires Xcode 26 or later (with the Xcode 16 series, macro
expansion for @Observable requires != and fails to build on SwiftData @Model
types). The Command Line Tools toolchain is enough for MuzzlemeterKit /
muzzlemeter-sniff alone.
After that:
open Muzzlemeter.xcodeproj # or xcodebuild -project Muzzlemeter.xcodeproj -scheme Muzzlemeter \ -destination 'platform=iOS Simulator,name=iPhone 17' build
Using xcodebuild requires Xcode's own toolchain to be selected (a one-time step you
do yourself, requires your password):
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
This is not needed for the CLI's swift build / swift test.
project.yml's DEVELOPMENT_TEAM has the author's own Team ID hardcoded (so it doesn't
need to be re-entered in Xcode after every xcodegen generate). If you fork this and
run it on real hardware, change it to your own Team ID. For the simulator no signing
is needed — add CODE_SIGNING_ALLOWED=NO and it builds as-is.
Since the app uses WeatherKit, building for real hardware requires an App ID with
WeatherKit enabled. If you don't need it, remove
com.apple.developer.weatherkit from App/Muzzlemeter.entitlements (environmental data
will just be empty; measurement itself is unaffected).
When the repository sits in a folder synced by iCloud, sync conflict copies (duplicate
files like Foo 2.swift) can end up in the build directory, and SwiftPM's build fails
with filename "..." used twice. Moving intermediate build products outside the
synced folder fixes this:
swift build --scratch-path /tmp/muzzlemeter-build swift test --scratch-path /tmp/muzzlemeter-build xcodebuild -project Muzzlemeter.xcodeproj -scheme Muzzlemeter \ -derivedDataPath /tmp/muzzlemeter-dd \ -destination 'platform=iOS Simulator,name=iPhone 17' build
| Environment | Transport |
|---|---|
| Real iPhone | CoreBluetoothTransport (actually connects to the AC6000) |
| Demo mode on (any build, any device) | ReplayTransport playing MuzzlemeterKit.DemoScript |
| Simulator, Debug | ReplayTransport (automatic, since there's no CoreBluetooth hardware) |
Any build + --replay / --replay-capture |
ReplayTransport |
A Release build on the simulator does not replay automatically. It starts from the same "nothing connected yet" state a real first-run device does, which is exactly the state demo mode exists for — and it is how demo mode gets verified in a build that is configured the way the shipped one is.
The decoder and configuration are the same in both cases
(MuzzlemeterDecoder + ChronoDevice.Configuration.ac6000()). Replay goes through the
same key-handshake path as real hardware, so behavior looks identical from the UI's
perspective (ReplayTransport.demoPeripheral carries the real device's own advertisement
00 05 08 c4 94 52 04).
There are two replay sources:
- Default (
--replay): a synthesized script inApp/Muzzlemeter/Services/ReplaySupport.swift. Has many shots, making it easy to build out the UI. The byte sequences are built using the real protocol. --replay-capture: fast-forward playback ofApp/Muzzlemeter/Resources/acesoft-iphone-rx.txt(an actual capture from the official app itself, including 5 shots and the power-off)
ReplayScript can also read the log format produced by muzzlemeter-sniff dump
directly, so a freshly captured log can be dropped in and replayed as-is.
Without an AC6000 in hand, every measuring screen in this app is empty. App Review has nothing to exercise, and someone whose unit has not arrived yet cannot see what the app does. Demo mode fixes that in Release builds, for anyone.
Turning it on
- Settings -> Demo mode -> Try demo mode.
- Or, on the Live screen, the No chronograph? Try it anyway card. It is shown only while no chronograph has ever connected on this install, so it disappears for good the first time a real device is paired.
Turning it off — the Live screen carries a Turn off demo mode button inside the banner that stays on screen for the whole demo, and the Settings toggle flips back. The way in and the way out are both one tap, and the way out is on the screen showing the numbers.
What it plays — Sources/MuzzlemeterKit/Demo/DemoScript.swift: an AEG at about
90 m/s on 0.20 g BBs (≈0.81 J, under the Japanese 0.98 J limit with a visible headroom),
six semi-auto shots followed by a twelve-round burst at ≈13 rps, looping. The bytes are
the real protocol, played by the same ReplayTransport and decoded by the same
MuzzlemeterDecoder as real hardware, through the same key handshake. So the Live
screen, the statistics, the energy-limit colouring, the rate of fire, the Live Activity,
the Home Screen widget and the Apple Watch app all run their production code paths.
While demo mode is on, the session's BB weight follows the script's 0.20 g rather than the profile default (0.25 g out of the box); otherwise the demo would open on a red "over the limit" screen that misrepresents the app. The gun profile itself is never modified — only the "conditions for the next session" value, which is restored when demo mode is turned off.
Never mistakable for a real measurement. This app is used to check whether a gun is under a field's energy limit, so a demo reading that looks real is a hazard, not just a confusion:
- The connection pill turns purple and reads
DEMO/ Demo mode — and unlike the developer replay note,--demo-hide-replay-badgecannot hide it. - A
DEMObanner sits above the numbers for the entire session. Session.isDemois written into the record at the moment the session is created (not decided at display time), so the badge cannot fall off later. It shows in History, in Session Detail (as the first row, above every number), on the Live Activity, on the Home Screen widget and on the watch.- The CSV export carries an
is_democolumn (demo/ empty). - Demo sessions are excluded from a gun profile's trends and statistics, with a note on that screen saying so. Marking them and leaving them in the series was rejected: one synthetic session silently skews the mean, the SD and the temperature slope, and "spot which scatter point is the demo" is not a thing to ask of someone reading a chart.
- Demo sessions record no weather and no location, and demo mode never triggers the location permission prompt. A synthetic shot carrying the real temperature where you are standing would be indistinguishable from a measurement.
Real hardware always wins. Two rules, and between them a demo shot and a real shot cannot land in the same session:
- While demo mode is on,
CoreBluetoothTransportis not constructed at all (ChronoService.makeDevice), so no chronograph can connect mid-demo. There is nothing to "stop the demo" for, because there is nothing to connect. - Demo mode cannot be started while a chronograph is connected (or being connected to). The Settings toggle is disabled and says why. Tearing down a live connection behind the user's back would leave the physical device's light on while the numbers on screen quietly became synthetic.
Turning demo mode on or off also ends any in-progress session first, and closes any demo
session left open by an earlier launch (DemoSessionStore.closeOpenDemoSessions).
Cleaning up — Settings -> Delete demo data removes every session with isDemo set,
and nothing else. The row only appears when there is demo data to delete, and shows the
count.
Things like the regulation-limit color coding or N-shot mode are only visible when
"specific values happen to be present." So that this state can be produced without
manually operating the simulator, ScreenshotSupport interprets the following arguments
only when running in the simulator. On an iPhone or iPad they are not even parsed, so
they have no effect on anything a user ever receives — TestFlight and the App Store take
device-architecture builds, and a simulator slice never reaches a distribution channel.
This used to be limited to DEBUG builds, which made a Release build impossible to check
visually (the simulator UI-automation tool does not work in this environment, so launch
arguments are the only way to reach a screen). "Is demo mode really usable in a Release
build?" is not a question worth asking if it cannot be answered, so the condition was
widened to the simulator.
xcrun simctl launch <udid> com.yhakamay.muzzlemeter --replay-capture \ --demo-energy-limit 0.001 \ # override the selected profile's regulation limit (J) --demo-target-shots 5 \ # override the target shot count (0 reverts to manual) --demo-tab settings \ # the tab shown at launch (live / sessions / settings) --demo-scan-sheet \ # open the device selection sheet right at launch --demo-latest-session \ # open the latest session's detail in the history tab --demo-seed-sessions \ # seed 5 sample sessions (only when the store is empty) --demo-compare \ # open the comparison screen for the last 3 sessions in the history tab --demo-tag-editor \ # open a sample session's detail and its tag editor sheet --demo-edit-conditions \ # create a dedicated demo session and open its conditions/gun editor sheet --demo-apply-bb-weight 0.32 \ # overwrite that demo session's BB weight directly, then open its detail --demo-scroll-stats \ # scroll the session detail down to the stats card on open --demo-scroll-chart \ # scroll to the velocity chart (session detail / the overlay on the comparison screen) --demo-filter 屋内 \ # apply a tag filter to the history tab --demo-profile-detail \ # open the first profile's detail in the settings tab --demo-device-log 12 \ # make the mock device report "12 records in its internal log" --demo-device-log-broken 4 \ # return the 5th of those records in an unsupported format --demo-device-log-auto \ # start the import without tapping the banner (for visually checking progress/results) --demo-scroll-demo \ # scroll the settings tab down to the demo-mode section --demo-hide-replay-badge # drop the "(demo replay)" suffix from the connection pill
Demo mode itself is a real setting, so it is switched with a UserDefaults launch
argument rather than one of the above — which is also what makes it reachable in a
Release build:
xcrun simctl launch <udid> com.yhakamay.muzzlemeter -muzzlemeter.demoMode YES
--demo-seed-sessions exists for screens that only appear once multiple sessions have
accumulated, like comparison, tags, and trends. It does nothing if sessions already
exist. The sample gun names, session titles and tags follow the display language (en /
ja / zh-Hant), so a screen captured in one language does not carry text from another.
--demo-scroll-chart and --demo-hide-replay-badge exist for App Store screenshots.
The charts sit below the gun, conditions and tag sections, so opening a screen is not
enough to get them into frame, and there is no tap-driven way to scroll here. The
connection pill says "(demo replay)" whenever the developer replay is running, which is a
developer note that does not belong in a store listing. It cannot hide the user-facing
demo mode's DEMO marker — that would turn a screenshot flag into a way to present
synthetic numbers as measurements.
--demo-edit-conditions and --demo-apply-bb-weight <g> exist for the finished-session
"edit BB weight / gun info" sheet (SessionConditionsEditor). Both create one dedicated,
finished demo session (ScreenshotSupport.makeConditionsDemoSessionIfNeeded) rather than
picking "whatever looks like a sample session" out of the history — picking one that way
turned out to drift between runs (a leftover unfinished session from a previous replay, an
older visual-check sample, etc.), which made the screen's contents unpredictable. The
simulator's UI automation tool is also unreliable in this environment, so instead of
tapping through the sheet, --demo-apply-bb-weight drives the real edit path
(ChronoService.saveEditedSession) directly and opens the session's detail already
showing the recomputed joules. Add --demo-scroll-stats to also scroll straight to the
stats card (further down the list), since the changed joules aren't visible without
scrolling and there's no tap-driven way to scroll in this environment either.
--demo-device-log exists for importing the device's internal log. The mock device (the
ReplayTransport responder) answers 0x62 / 0x63, so the app side goes through the
same path as real hardware (write -> notify -> decode). It holds 1 record by default
(matched to the count that 0x62 answered with in the real capture).
Adding --demo-device-log-broken <n> makes only the nth record come back unreadable, so
the "unsupported format — the log was saved" message and the raw-data export can be
checked visually. --demo-device-log-auto exists to show the screen right after tapping
"import" (progress and results). Since a real-capture replay stays connected for less
than 10 seconds, tapping it by hand and catching the progress in time is a matter of
luck. With this flag, the import starts automatically as soon as the count is known.
⚠️ What the mock device returns for0x63is a guess (docs/PROTOCOL.md§6.6). Once the real-hardware format is known,ReplayTransport.deviceLogResponderwill be replaced along with it.
On the first shot of a session, the current conditions are fetched once from WeatherKit
and CoreLocation and recorded on the Session (SessionEnvironmentService). The fetch
runs as a detached task, so measurement is never blocked at all; if it fails, the
automatic value is simply left empty. The values can be overridden by hand from the
detail screen's "Environment" section, and the effective value is manual ?? auto.
The WeatherKit entitlement doesn't work in the simulator, so no value can be fetched
there. So the UI can still be checked visually, SessionEnvironmentService has a path
that returns sample values, but only when running --replay-capture in the simulator
(#if targetEnvironment(simulator)). It never runs on the real-hardware code path.
-
Power on the device and place it near the Mac
-
From Terminal.app / iTerm.app (an integrated terminal from another app won't work):
swift run muzzlemeter-sniff dump --name AC6000BT- --handshake
-
If
-> ACK for READ_KEYappears, the handshake succeeded. Fire a BB and compare theFIRE_REPORT rawSpeed=...line against the device's own LCD -
The log is kept at
tools/re/captures/<date-time>.log, ready to use directly inTests/MuzzlemeterKitTests/Fixtures/or a replay script
Things still unverified (docs/PROTOCOL.md §10):
- The unit of
rawRev(rate of fire) — fire several rounds full-auto and compare against the interval between shots - The wire scale for BB weight (×ばつ100 or ×ばつ1000) — watch
0x47while changing the weight setting on the device - The first-pairing path when the key is unknown (implemented, but never observed against a real device)
- The request/response shape for
0x63(one device-log record) — see the output ofswift run muzzlemeter-sniff dump --name AC6000BT- --read-log
Since the BLE spec isn't published by the manufacturer, it was worked out by measuring communication with my own unit.
- Results:
docs/PROTOCOL.md(UUIDs, frame format, checksum, key handshake, opcode table) - Only two sources were used:
- HCI logs captured on my own iPhone (a
.pklgfrom Apple's PacketLogger) - On-device testing with the custom client
muzzlemeter-sniff
- HCI logs captured on my own iPhone (a
- See
tools/re/README.mdfor how the research was carried out
docs/PROTOCOL.md documents only observed facts, each labeled with its confidence
(confirmed / presumed / unverified). What's still unfilled is collected in §10.
- Bluetooth is used only to talk to the chronograph. It never connects to any other device.
- Location is fetched once, on a session's first shot, solely to look up temperature/humidity/pressure at that spot via WeatherKit. The location itself is never stored.
- Measurement data is stored only in the on-device SwiftData store. CSV export only runs when the user explicitly triggers it.
- No analytics or tracking SDK is included, at all.
- No network traffic occurs other than WeatherKit.
- This app is an unofficial app unaffiliated with Acetech, and is not approved, supported, or warranted by the manufacturer. Acetech, AC6000, and AceSoft are trademarks of their respective owners.
- This app is not a tool for determining legal or regulatory compliance. The displayed velocity and energy are reference values converted directly from the chronograph's own output, and are subject to measurement error, BB-to-BB variance, and configuration mistakes. Confirming that muzzle velocity or energy does not exceed the limits set by law or field rules is the user's own responsibility, to be done through officially recognized methods.
- This software is provided with no warranty, as stated by the MIT license. Writes to the device are restricted to read-only operations, but the author accepts no responsibility for any damage, including device failure or data loss.
Issues and pull requests are welcome. Reports on whether it works on an AC6000 BT
(non-MKIII) are especially appreciated, along with captures that resolve any of the
unverified items in docs/PROTOCOL.md §10.
- Commit messages must carry an ADR (Context / Decision / Alternatives considered /
Consequences) in the body, for any commit that involves a design choice, so it can be
traced later. See
.github/PULL_REQUEST_TEMPLATE.mdfor details. - Run
swift testbefore opening a PR, and if the change needsxcodegen generate, editproject.ymlaccordingly (.xcodeprojitself is never committed). - Information obtained by decompiling someone else's app is not accepted. Only
observed facts obtained from your own communication captures and on-device testing may
be added to
docs/PROTOCOL.md. - PRs that add dangerous operations (writing to the OTA characteristic,
0x61CLEAR_LOG) will not be accepted. Seedocs/PROTOCOL.md§11.
MIT © 2026 Yusuke Hakamaya