Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ if(BUILD_TESTING)
termforge_add_python_test(binary_paths -m unittest experiments.test_build_paths -v)
termforge_add_python_test(conpty_margin_contract -m unittest experiments.conpty.test_margin_contract -v)
termforge_add_python_test(benchmark_tools -m unittest discover -s benchmarks/tests -v)
termforge_add_python_test(windterm_alternate_export -m unittest research.ui.test_export_alternate_reference -v)
termforge_add_python_test(differential -m unittest discover -s experiments/differential -p test_*.py -v)
termforge_add_python_test(ime_inventory -m unittest research.ime.test_hosted_inventory research.ime.test_prepare_hosted research.ime.test_product_ime -v)
termforge_add_python_test(text_actions -m unittest experiments.shell.test_actions -v)
Expand Down
55 changes: 55 additions & 0 deletions docs/research/windterm-alternate-reference-2026-10-28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# WindTerm 2.7.0:主/备用屏模式的直达终端观察

本次 [#118](https://github.com/JavaZeroo/termforge/issues/118) 在固定原版上完成一次 26 阶段的合成输入。它提供原版终端的文字投影和光标协议回复,不能代替本地 Shell 运输、逐格样式、滚动区域或完整 R2 验收。自主 C++ 后续实现应保留下面的原版事实,不能直接把通用规范或旧候选结果当作原版合同。

## 身份与方法

固定官方 2.7.0 Windows x64 EXE SHA-256 为 `7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0`;复制的 4,047 个发行文件按既有 `e459adf2…203` 清单守卫核对。完整来源、探针哈希、精确输入、每阶段输出及限制见[白名单摘要](../../research/ui/results/2026-10-28-alternate-reference/validation.json)。REA Frida Evidence ID 为 `ev_3bdab796b335d64f0ffc8dc26bbcfd3c1441a2805993157451f8602311609a6f`。

复用 [r1_launch.py](../../research/ui/r1_launch.py) 的独立 WindowStation/Desktop、挂起创建后分配 OwnedJob、独立 HOME 和单个合成 profile;没有切换用户桌面、访问剪贴板或修改显示设置。合成 Telnet peer 只监听 `127.0.0.1` 的随机端口,直接发送 VT,避开 WinPTY/ConPTY 的本地控制台改写。最终 NAWS 为 **91×25**,所有已存 UI 快照 capacity 均为 25;没有校准或重试。源生成器在首次 NAWS 时冻结压力输入,实际源唯一对应其 rows=24 分支,初始 NAWS 值及转换序列未单独保留,不能声称启动全过程网格固定。原版最多运行一次。

`rea_capture_seconds=45` 是传给 REA `--duration-ms 45000` 的捕获时长;复用启动器以 `seconds+15` 设置 CLI 等待超时,因此 `cli_wait_timeout_seconds=60`。原版在 CLI 启动前已恢复运行,期间还有单独的 2 秒等待,CLI 返回后再执行资源回收。**45 秒不是整个原版进程生命周期或整个启动器的墙钟上限**,60 秒也仅约束 CLI 等待。本记录不提供整程生命周期上限保证。

[公开探针](../../research/ui/alternate_reference.py) 在每阶段发送独立输入及 `CSI 6n`,等原版 UI 投影连续稳定后保存快照,再经文件确认允许下一阶段发送。收到 **26 条回复、26 份快照和 26 次阶段确认**;应用回复恰好是 26 条 `CSI row;column R`,没有 `?` 前缀或其他应用字节。完整源十六进制及回复逐项核对,不能把这次 TCP 传输解释成逐字节/任意分块回归。探针没有在首次或重复 `1049h` 后补发 ED/H。

## 已观察事实

以下光标均为 **DSR 的一基行、列**。这是原版终端主动发出的协议回复;UI 的 `caretColumn` 在全程一直为 5,不能把该 UI 字段当作终端光标。

| 输入或阶段 | 原版观察 |
|---|---|
| 主屏写 `MAIN_A`,定位 `3;6H` | DSR `(3,6)`;文档第 0 行为 `MAIN_A` |
| 第一次 `?1049h` | DSR `(1,1)`;文档仍返回原主屏文字,未取得完整 active grid |
| 写 `ALT_ONE`,定位 `5;9H`,再次 `?1049h` | DSR 从 `(5,9)` 回 `(1,1)`;`ALT_ONE` 从返回的文字投影消失,文档行数 7→3,scrollTop 2→0 |
| 备用屏写 `DUP` 后 `?1049l` | DSR 恢复 `(3,6)`;主屏 `MAIN_A` 保持,`DUP` 不在返回投影中 |
| 主屏写 `M`,再次 `?1049l` | `M` 位于文档第 2 行、零基第 5 列;重复退出保持 DSR `(3,7)` |
| 重新 `?1049h` | DSR `(1,1)`,旧 `DUP` 不在返回投影中 |
| 备用屏写超过一屏的 `ALT_LINE_00…28` | DSR `(25,12)`;投影保留 `ALT_LINE_04…28`,更早四行及 `ALT_TWO` 不在返回投影中 |
| 压力后 `?1049l` | DSR 恢复 `(3,7)`,投影恰好恢复主屏 `MAIN_A` 和 `M`,未带入 ALT 标记 |
| 随后 `?47h` | 再次取得前次备用文字和 DSR `(25,12)`;`47l` 后主屏仍 `(3,7)` |
| `47` 备用屏定位 `(6,11)`、退出再进入 | 备用文字保持、DSR 仍 `(6,11)` |
| 随后 `?1047h` | 保留此前 `47` 备用文字和 DSR `(6,11)` |
| `1047` 备用屏定位 `(7,12)`、退出再进入 | 备用文字保持、DSR 仍 `(7,12)` |
| `8;13H` 后 `?1048h`,再定位 `12;20H`、`?1048l` | 仍回复 `(12,20)`;随后 `Q` 确实写在文档第 11 行、零基第 19 列,DSR `(12,21)` |

不能将 `1049h` 的重复进入按一般的“当前已在备用屏则不做事”处理并声称原版一致。`1048` 本次没有恢复光标;这只约束这个具体输入,不证明所有 `1048` 或其他保存恢复路径均不支持。`47` / `1047` 是在已有 `1049` 内容后观察,未覆盖全新会话的首次进入及全部交叉转换。

## 文档投影与未知项

`activeText.lineText` / `visibleLineCount` 返回的是保留文档。备用屏期间仍能读到原主屏 `MAIN_A`,`scrollTop` 随备用输出变化,因此不能把整个数组当成 active screen。压力后共 28 行,其中主屏仍占前三行;备用投影为 25 行。本次只枚举 `[0, visibleLineCount)`,不证明该区间就是完整 active grid,也没有调用该区间以外的 `lineText`。原始投影保持不变,不用候选结果补造 viewport 映射。

重复和重新 `1049h` 的归位、旧 ALT 标记消失,是已观察事实;它们与备用区清空一致,但**完整 active grid 是否清空仍未知**。首次进入的默认空白格、当前样式是否继承或恢复、逐格颜色、pending-wrap、主历史在备用期间的可浏览方式及真实选择行为均未取到。暴露的 `isAltScreen` / `isMainScreen` 名称本次仅被枚举,没有调用,字段保持未知。

原版 UI 光标 API、DSR 光标与文档行号分别保存,未混作一套坐标。`cell_styles` / `current_style` 为 `null`,接口名不构成样式证据。此观察不能批准标准行为差异或原版缺陷修复,更不解除 #41/#56 的本地 Shell 原生 margin 差异。

## 回收、复现与实现边界

REA 正常结束,无消息截断、脚本错误、清理错误或 stderr;自有 Job 最终 `active=0,total=3,terminated=0`,profile 仍只有一个合成 Telnet 会话。启动器主动结束原版窗口,计数不是自然 GUI 退出证明。provider 的 subject/environment 为 null,包身份由独立准备守卫保证。

原始插桩、绝对路径、PID、复制的原版资源和合成 profile 保留在 `local/alternate-reference-0`;公开内容仅包含自有探针、合成字节和脱敏结果。现有本地证据可用 [export_alternate_reference.py](../../research/ui/export_alternate_reference.py) 重新审计导出,不需要再启动原版。重新执行调查须另建输出目录并声明次数及边界,不把本次一次观察改写成重复可靠性或性能结论。

导出门槛使用显式 `require` / `ValueError`,在 `python -O` 下仍核对身份、来源、消息一致性、确认文件、回收与合成文本白名单。文本仅允许本夹具的标记及已有覆盖片段,不能因为字符均为大写就放行未知文字;所有门槛通过后才创建导出目录和文件。[公共 CLI 回归](../../research/ui/test_export_alternate_reference.py) 使用自造记录,在普通和优化解释器下分别验证有效记录及私有文本、错误身份、变更脚本/输入、快照不一致、错误阶段确认和未回收 Job 的拒绝路径,不依赖原版资源。

离线修复前,该回归复现了 9 个拒绝用例失败及 2 个新时长字段缺失;修复后,`python -m unittest research.ui.test_export_alternate_reference -v` 和 `python -O -m unittest research.ui.test_export_alternate_reference -v` 各 8 项通过,每次覆盖 20 次公共导出 CLI 调用。有效原始记录也经普通及 `-O` 导出得到相同 JSON;与首次摘要相比,26 阶段输入、文字/光标观测、provider、原始文件哈希和原版身份均不变。修复只更新离线导出校验、时长含义及导出来源标识,没有新增原版运行。

首个 C++ 模式切片可以据此锁定已观察光标转换及主屏文字恢复,并为未知的清空、样式、历史投影与更复杂交叉继续取证。实现仍需独立验证全局行身份、版本、选区失效和有界主/备用历史;原版文档布局不要求内部容器结构相同。完整 #27 以及 R2 均未完成。
40 changes: 40 additions & 0 deletions research/ui/alternate_reference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Original UI-state observation only. No clipboard, user settings or physical input.
let stage=0,busy=false,finished=false,due=0,lastPhase=-1,stable=null,stableSince=0;
const started=Date.now(),observations=[];
function currentTerminal(){
const raw=evalText(`JSON.stringify((function(){var t=window.activeText;if(!t)return null;function get(k){try{var v=typeof t[k]==='function'?t[k]():t[k];return v===undefined?null:v;}catch(e){return null;}}var n=get('visibleLineCount');var lines=[];if(typeof n==='number'&&n>=0&&n<=200){for(var i=0;i<n;i++)lines.push(t.lineText(i));}return {protocol:get('protocolName'),connected:get('isConnected'),top:get('topLine'),scroll:get('scrollTop'),line_count:n,capacity:get('screenLineCapacity'),lines:lines,caret_api:{caretLine:get('caretLine'),caretColumn:get('caretColumn'),caretPosition:get('caretPosition'),selectionCaretP:get('selectionCaretP')},cell_styles:null,current_style:null,api_names:Object.keys(t).filter(function(k){return /style|colou?r|attribute|caret|cursor|line.*count|screen/i.test(k);}).slice(0,180)};})())`);
return raw?JSON.parse(raw):null;
}
function finish(status,error){
if(finished)return;finished=true;
const result={status,error:error||null,observations,terminal_style_values_verified:false,local_shell_transport_verified:false,presented_done:null};
const file=new File(__RUN_DIRECTORY_JSON__+'/probe-result.json','w');file.write(JSON.stringify(result));file.close();send({kind:'alternate_reference_result',...result});
}
send({kind:'probe_ready',station_isolated:true,scenario:'alternate-reference-loopback'});
Interceptor.attach(user.getExportByName('DispatchMessageW'),{onEnter(){
if(busy||finished||Process.getCurrentThreadId()!==uiThread||Date.now()<due)return;busy=true;
try{
if(wind.base.add(0xc29394).readU8()!==0)throw Error('first-run guard');
if(Date.now()-started>41000)throw Error('bounded scenario deadline');
if(stage===0){if(!ready('ui'))return;resize(main,1360,860);stage=1;}
else if(stage===1){scan();expandAll(tree);focus(tree,7);key(tree,0x1000011);stage=2;}
else if(stage===2){key(tree,0x1000004);stage=3;}
else{
const peer=peerState();if(!peer||!peer.connected||peer.phase===null)return;
if(peer.complete){finish('observed-all-phases',null);return;}
if(peer.error)throw Error('peer '+peer.error);
if(peer.phase!==lastPhase){lastPhase=peer.phase;stable=null;stableSince=Date.now();}
const terminal=currentTerminal();if(!terminal||!terminal.connected||terminal.protocol!=='Telnet')return;
const state=JSON.stringify(terminal);
if(state!==stable){stable=state;stableSince=Date.now();return;}
const record=peer.records[peer.phase];
if(Date.now()-stableSince<450||(!record.cursor_reply&&Date.now()-stableSince<900))return;
const observation={phase:peer.phase,label:peer.phase_label,terminal,cursor_reply:record.cursor_reply};
observations.push(observation);send({kind:'alternate_phase',...observation});
const ack=new File(__RUN_DIRECTORY_JSON__+'/ack-'+peer.phase,'w');ack.write('observed');ack.close();
stableSince=Date.now()+100000;
}
due=Date.now()+75;
}catch(e){finish('failed',String(e));}finally{busy=false;}
}});
setTimeout(()=>finish('failed','bounded observation timer'),42000);
Loading