Metadata-Version: 2.4
Name: offline-oj
Version: 2.1.0
Summary: 离线 OJ 系统 —— 面向 Windows 10/11 的本地代码评测客户端
Author: Offline OJ Project
License: MIT
Keywords: oj,judge,offline,windows,desktop
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Win32 (MS Windows)
Classifier: Intended Audience :: Education
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
Classifier: Operating System :: Microsoft :: Windows :: Windows 11
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Education
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PySide6<7,>=6.6
Requires-Dist: psutil>=5.9
Provides-Extra: build
Requires-Dist: pyinstaller>=6.6; extra == "build"
Requires-Dist: pillow>=10.0; extra == "build"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-qt>=4.4; extra == "dev"
Dynamic: license-file

# 离线 OJ 系统 v2.0

[![CI](https://github.com/bilibiliUID1480494301/offline-oj/actions/workflows/ci.yml/badge.svg)](https://github.com/bilibiliUID1480494301/offline-oj/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%2010%20%2F%2011-blue)]


面向 **Windows 10 / 11** 的本地代码评测客户端。在本机编译并运行 C / C++ / Python / Java  
代码，按测试点判定结果，题库与提交记录完全保存在本地，不需要联网。

判题支持两种数据通道与两种判定方式：标准输入输出 / **题目指定的文件**，  
精确比对 / **自定义校验器（特殊判题）**。四个选项都按题配置，都不配就是最传统的行为  
（详见「[判题方式](#判题方式文件输入输出与特殊判题)」）。

本版本是对原单文件 Tkinter 程序（`legacy/OJ.py`，2455 行）的一次完整重构：  
改为 PySide6 界面 + 分层架构 + Windows 平台规范 + 可打包分发。

---

## 快速开始

**pip 安装（推荐）**：

```bash
pip install offline-oj
# 命令行评测（无 GUI）：
python -m offline_oj.cli --help
# 图形界面：
offline-oj
```

内核（评测/导出/雷同检测）与协议栈（加密/局域网会话）可作为库导入：
`offline_oj.core.*`、`offline_oj.net.crypto`（零依赖 ChaCha20/X25519/SM4 套件）。

**源码运行**：

```powershell
# 1. 安装依赖
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt

# 2. 运行
.venv\Scripts\python.exe -m offline_oj
```

首次启动后：

1. **编译器配置** → 点「自动检测」→ 点「验证可用性」→ 保存；
2. **存题模块** → 新建题目，填描述与测试点；
3. **写题模块** → 写代码，`Ctrl+Enter` 提交评测。

### 运行环境：为什么是 Windows 10 1809+ / 11，不是 7 或 8.1

**7 和 8.1 都跑不了，而且不是本项目的选择 —— 是 GUI 栈的硬下限。**

| 组件 | 对 Win7 | 对 Win8.1 | 依据 |
|---|---|---|---|
| Python 3.13 | ✗ 需降到 3.8 | ✓ | PEP 11：只在微软仍提供扩展支持的系统上支持。[^pywin] |
| Qt 6 / PySide6 | ✗ | ✗ | Qt 6 明确不再支持 Windows 7 与 8.x。[^qt6] |

也就是说 **8.1 是被 Qt 卡住的**（Python 那边没问题），**7 是被两边同时卡住的**。
Qt 最后一次支持 Win7 是 5.15 LTS、支持 8.1 是 5.12 LTS —— 也就是说要让 8.1 上跑，
得把整个界面层从 PySide6 换回 **PySide2 / Qt 5.15**；要 7 还得连 Python 一起降到 3.8。

本次没有这么做，因为代价和收益不成比例：界面层要过一次 PySide2 的 API 差异
（枚举、信号、`QAction` 的归属都变了），Python 停在 3.8 意味着三年前的类型语法与库版本，
而 7 / 8.1 早已不在微软的扩展支持范围内（没有安全更新）。如果确实有硬件只能跑 8.1，
那属于要单独立项的事，不是改个 `MinVersion` 就能过的。

安装包与清单都以 10 为准：`packaging/installer.iss` 是 `MinVersion=10.0`（装都装不上），
`packaging/app.manifest` 的 `supportedOS` 只声明 Windows 10 / 11。

[^pywin]: Python 3.13 的 Windows 安装说明："Python 3.13 supports Windows 8.1 and newer. If you require Windows 7 support, please install Python 3.8."
[^qt6]: Qt 官方《Host operating systems in Qt 6.0》："Both Windows 7 or 8.x version support will not be available for Qt 6." 现行的 [Supported Platforms](https://doc.qt.io/qt-6/supported-platforms.html) 列的是 "Windows 10 (1809 or later) / Windows 11"。

## 构建发布产物

```powershell
# 只打绿色版（dist\OfflineOJ\，可直接运行）
pwsh packaging\build.ps1 -Clean

# 绿色版 + 便携版 ZIP（dist\portable\OfflineOJ-<版本>-win64-portable.zip，解压即用）
# 这条不需要 Inno Setup，适合"装不了/不想装 Inno Setup"的场景
pwsh packaging\build.ps1 -Clean -Portable

# 绿色版 + 安装程序（dist\installer\*.exe，需先装 Inno Setup 6）
pwsh packaging\build.ps1 -Clean -Installer
```

> 若提示"禁止运行脚本"，在当前进程内放开即可（不改系统设置）：  
> `Set-ExecutionPolicy -Scope Process Bypass -Force`



## 目录结构

```
offline_oj/
├─ app.py               启动装配：DPI → 日志 → 单实例 → 主窗口 → 异常兜底
├─ paths.py             目录规约（%LOCALAPPDATA%）
├─ settings.py          设置持久化（原子写 + 损坏隔离）
├─ logging_setup.py     滚动日志 + 未捕获异常钩子
├─ context.py           依赖注入容器（AppContext）
├─ cli.py               无界面命令行入口
├─ win32/               ── Windows 平台集成层
│   ├─ dpi.py               Per-Monitor V2 高 DPI
│   ├─ appid.py             AppUserModelID（任务栏归组）
│   ├─ single_instance.py   命名互斥体
│   ├─ volumes.py           固定驱动器枚举（跨盘符扫描的基础）
│   ├─ msvc.py              Visual Studio 定位与 cl.exe 编译环境组装
│   └─ process.py           无窗口建进程 / 杀进程树 / 驱动器类型
├─ core/                ── 评测内核（不依赖界面，可单测）
│   ├─ models.py            Language / Verdict / TestCase / Problem
│   ├─ validation.py        路径校验与防目录穿越
│   ├─ compilers.py         编译器探测（PATH / 注册表 / 常见目录 / VS 布局）
│   ├─ sandbox.py           运行结果、编译器锁、时间内存监控
│   ├─ runners.py           四语言"编译 + 运行"+ 编译器家族差异（CompileProfile）
│   ├─ checker.py           自定义校验器（编译一次，逐测试点按协议裁决）
│   ├─ judge.py             评测编排（事件流）
│   ├─ security.py          危险调用静态检查
│   ├─ repository.py        题库持久化 + 提交历史
│   └─ archive.py           单题/批量导入导出
├─ net/                 ── 局域网测验（不依赖界面，可单测）
│   ├─ crypto.py            ChaCha20-Poly1305（RFC 8439）+ scrypt 密钥派生，零依赖
│   ├─ protocol.py          加密帧编解码（长度前缀 + 方向位 + 单调序号，防重放与反射）
│   ├─ session.py           房间号、设备八位 ID、测验会话、本场策略、提交与排名的数据模型
│   ├─ identity.py          设备标识的生成与持久化（device.json）
│   ├─ server.py            主机端：房号握手、下发题面、判题队列、放榜策略
│   └─ client.py            客户端：连接、拉题面、提交、收结果
└─ ui/                  ── PySide6 界面层
    ├─ theme.py             浅色/深色主题、字体回退链、QSS
    ├─ widgets.py           代码编辑器（高亮/补全）、路径选择器、Markdown 预览
    ├─ workers.py           QThread 工作线程
    ├─ single_instance.py   互斥体 + 管道唤出已有窗口
    ├─ main_window.py       菜单 / 选项卡 / 状态栏
    └─ panels/              六个功能面板（含局域网测验的主机端与学生端）

packaging/                图标生成、spec、manifest、版本资源、Inno Setup 脚本
tools/                    开发辅助脚本（真实判题验收、判题方式验收、MSVC 验收、截图、稳定性复跑）
tests/                    core 层单元测试 + 补全单测 + 打包一致性单测 + conftest + 离屏界面冒烟测试
legacy/OJ.py              重构前的单文件实现（仅作对照保留）
```

## 与原版本的主要差异

| 方面    | 原实现                                     | 现在                                                                              |
| ----- | --------------------------------------- | ------------------------------------------------------------------------------- |
| 代码组织  | 单文件 2455 行                              | 分层包，core 与 UI 完全解耦                                                              |
| 界面    | Tkinter，默认主题                            | PySide6，浅色/深色主题，代码高亮、行号、代码提示                                                    |
| 用户数据  | 写在 exe 同级目录                             | `%LOCALAPPDATA%\OfflineOJ`（Program Files 下也能正常工作）                               |
| 保存    | 直接 `open(...,'w')`                      | 临时文件 + 原子替换 + `.bak` 备份                                                         |
| 多开    | 可无限多开，互相覆盖数据                            | 单实例互斥体 + 唤出已有窗口                                                                 |
| 高 DPI | 无感知，125% 缩放发虚                           | Per-Monitor V2 + 清单声明                                                           |
| 任务栏   | 与所有 Python 程序挤在一起                       | 显式 AppUserModelID                                                               |
| 线程    | `threading` + `root.after` 混用           | `QThread` + 信号槽，UI 不会被阻塞                                                        |
| 判题输出  | 直接往控件里 insert 字符串                       | 事件流，可单测、可做进度条                                                                   |
| 数据通道  | 只有标准输入输出                                | 可按题改成**文件输入输出**（题目指定文件名）                                                        |
| 判定方式  | 只有"规范化后逐行相等"                            | 可按题挂**自定义校验器**，用于答案不唯一 / 浮点容差 / 顺序无关的题目                                         |
| 题库共享  | 无                                       | **局域网加密共享**：开启房间答题、双模式放榜、设备八位 ID 身份、榜单带耗时内存与第几次提交                                                  |
| 测验留档  | 关房即清，考完什么都没留下                            | **关房自动留档 + 回看**：整场写进独立目录（题目快照 / 逐份提交 / 榜单 / 校验和），可回看、打开目录、删除；可设"只存成绩不存代码"                     |
| 日志    | `print`（GUI 模式下丢失）                      | 滚动日志文件 + 崩溃兜底对话框                                                                |
| 安全    | 无路径校验，ZIP 可目录穿越                         | `safe_join` 拒绝逃逸，导入包不可信                                                         |
| 编译器探测 | 写死 `C:\Program Files\Dev-Cpp\...` 等几个目录 | PATH + 注册表 + **遍历全部固定盘符** + Visual Studio 布局（`vswhere` + `winreg`），GCC 优先于 MSVC |
| 编译标准  | 写死一个 `-std=`                            | 按编译器能力选择并缓存（旧 GCC 不支持 `c++17`、MSVC 不认 `/std:` 时静默降级，各有各的处理）                     |
| 分发    | 一个裸 `.py`                               | onedir exe（图标/版本资源/清单）+ 安装程序                                                    |

## 代码编辑器

写题模块的编辑器是自建的（`offline_oj/ui/widgets.py`），不依赖任何第三方编辑控件：

| 能力   | 说明                                                                                 |
| ---- | ---------------------------------------------------------------------------------- |
| 语法着色 | 注释、字符串、数字、关键字、预处理指令；`/* */` 跨行状态；浅色/深色各一套配色                                        |
| 前缀补全 | 输入 2 个及以上字符自动弹出；`Ctrl+Space` / `Alt+/` 手动唤出；`↑↓` 选择、`Enter`/`Tab` 采纳、`Esc` 关闭      |
| 候选来源 | ① 按语言预置的关键字 / 类型 / 标准库 API；② 代码片段；③ 当前文件里出现过的标识符                                   |
| 代码片段 | `main`、`fori`、`forj`、`foreach`、`readarray`、`fastread`、`scanner` … 展开成多行模板，光标落在待填写处 |
| 编辑体验 | 行号、当前行高亮、Tab 转 4 空格、`Shift+Tab` 反缩进、回车自动缩进                                         |

关于补全的定位：**这是前缀式词补全，不是 IDE 的语义补全**。没有编译器前端就拿不到类型信息，  
所以 `obj.` 之后列成员这类功能做不到 —— 换来的是零依赖、完全离线、开销可忽略。

补全的候选列表用自绘代理（`CompletionDelegate`）渲染，因为 `QCompleter` 的弹出列表是单列  
`QListView`（一旦设置 `modelColumn`，Qt 就会隐藏其余列），类型标签放不进第二列，只能自己画。

## 判题方式（文件输入输出与特殊判题）

这两项都在「存题模块 → 判题方式」里**按题**配置，默认值就是最传统的行为。

### 文件输入输出

有些题目要求程序从指定文件读入、把答案写进指定文件，而不是走标准输入输出。  
把「输入输出方式」改成**文件输入输出**，再填两个文件名即可。

- 文件放在该测试点自己的工作目录里，程序用**相对名**打开就能读到；
- 每个测试点开跑前会先删掉上一轮留下的输出文件 —— 否则程序这一轮什么都没写，  
  却会读到上一轮的答案，被误判成通过；
- **程序没生成输出文件一律判 WA**。这条必须写死：如果只是让标准输出保持为空，  
  而该测试点的期望输出恰好也是空的，两边空字符串相等就会判成 AC ——  
  把一个明显的错误判成通过，是最不能接受的一类误判。所以这种情况还会额外给出提示：  
  "程序把内容打印到了标准输出，但本题的答案要写进文件"；
- 文件模式下不喂标准输入。真正读文件的程序不受影响，而误用 `scanf` / `input()` 的程序  
  会立刻拿到 EOF，错得更早，也更容易看出原因。

### 自定义校验器（特殊判题）

答案不唯一、允许浮点误差、与顺序或空白无关的题目，逐字符比对必然误判。  
勾上「使用自定义校验器」并写一段校验器源码，判定就交给它。

协议沿用在线评测界的通行做法，三个文件路径按顺序走命令行参数：

```text
校验器 <输入文件> <选手输出文件> <标准答案文件>

退出码 0 → 答案正确      → AC
退出码 1 → 答案错误      → WA
退出码 2 → 格式错误      → PE
其它 / 超时 / 编译失败    → IE（评测机内部错误）
```

三条实现约定：

1. **校验器自己出问题，绝不算到选手头上。** 编译不过、跑超时、异常退出都记成 IE，  
   `compile_ok` 仍为真；结果面板会写明"自定义校验器不可用"并提示去存题模块检查。
2. **每题只编译一次**，所有测试点复用同一份产物。
3. **校验器源码存在题目 JSON 里**，随导出 / 导入包一起走，不需要额外分发文件；  
   没配校验器的题目存出来的 JSON 与老版本逐字节一致。

用"浮点容差"这个最典型的场景感受一下（`python tools\e2e_judge_config.py` 会真跑一遍，  
题目是"输入 n 输出 sqrt(n)，允许 1e-5 误差"）：

| 选手输出                          | 精确比对   | 挂了容差校验器 |
| ----------------------------- | ------ | ------- |
| `1.414214`                    | AC     | AC      |
| `1.4142135624`（数值对、位数不同）      | **WA** | AC      |
| `1.4242135624`（差了 1e-2）       | WA     | WA      |
| `sqrt = 1.414214`（数值对、混了说明文字） | WA     | **PE**  |

校验器语言只提供 **C++ 与 Python**：校验器主要在处理字符串与空白，这两种写起来最省事，  
在线评测里的校验器也几乎只有这两种写法。C 与 Java 的配置即使写进 JSON 也会被忽略。

> 写校验器最容易踩的一点：**三个参数都是相对文件名**，校验器以"当前测试点目录"为工作目录  
> 运行。所以 `ifstream(argv[1])` / `open(sys.argv[1])` 直接就能打开；用相对名也顺手避开了  
> "中文用户名 → C 运行库把命令行转成 ANSI 代码页 → 找不到文件"这条老路。

### 编译优化（-O2）

C / C++ 提交默认按 **-O2**（MSVC 为 `/O2`）编译，可以关掉按 `-O0` 编 ——
同一份代码两边耗时能差好几倍，拿它对照一次就能看清"是算法不够快，还是只差个优化"。

开关放在三个地方，各自解决一件事：

| 位置 | 作用范围 |
|---|---|
| 「高级设置 → 判题选项」 | 全局默认值 |
| 「写题模块」编辑器上方的 **O2 优化** | 本次提交；新开面板时取全局默认 |
| 「局域网测验 → 房间设置」的**判题时开启 O2 优化** | 整场测验，开启房间后锁定 |

最后一条是主机端的决定：判题在主机的机器上做，同一场测验里所有人的编译参数必须一致，
否则榜单上的耗时不具可比性 —— 所以它跟着房间走，学生端既看不到也改不了。

> 「测试运行」用的是**同一个**开关值。这不是顺手，是必须的：自测按 -O0、判题按 -O2，
> 那么"自测挺稳"到了判题就可能变成 TLE（或者反过来白等一场）。

## 局域网测验（共享题库）

老师在一台机器上开启房间，学生用「地址 + 端口 + 房间号」进场做题。**判题在主机的机器上做**，
学生机不需要装任何编译器 —— 这条同时决定了测试数据永远不离开主机。

### 两种模式，外加一个正交的放榜开关

模式只决定**榜单什么时候公开**，判题、计分、排名规则完全共用同一套代码，
不是两份实现，只有一个 `leaderboard_visible()` 分支。

| 模式 | 榜单 | 适用 |
| --- | --- | --- |
| **练习模式** | 全程实时更新 | 课堂练习、随堂测 |
| **考试模式** | 结束后统一放榜（含 30 秒收卷宽限） | 正式测验 |

再往上一层还有一个**「公开榜单」**开关，它与模式**正交**（一个是"什么时候放"、
一个是"放不放"）。关掉之后**任何模式下都不放榜**，包括练习模式的实时榜 ——
这样"只测验、不打榜"就是「考试模式 + 关掉公开榜单」这一个组合，不必为它再造第三种模式。

封榜期间学生看不到别人的成绩，但**能看到自己那一行** —— 关榜不该连自己的排名都没有。

### 本场限制（语言与功能开关）

开房时可以收窄这一场允许的东西，都在「房间设置」下方的**本场限制**里：

| 开关 | 关掉之后 |
|---|---|
| **允许的语言**（C++ / C / Python / Java 四个勾） | 该语言不再出现在学生端的语言下拉框里 |
| **允许学生把代码存成文件** | 学生端的「另存为…」被禁用，代码带不出考场 |
| **提交判定后锁定编辑器** | 每份代码判定回来即锁定编辑器（一题只交一次的场合有用） |

**服务端会再校验一次语言。** 界面上的置灰只是"别让人白点一次"——学生端是可以被改的
（改 exe、改内存、直接发包都行），所以 `server.py` 收到提交时会拿本场策略再判一遍，
不在允许列表里就回 `ERROR`。这是安全边界，不是体验优化；`test_net_lan.py` 里有一条
**绕开界面直接发包**的用例盯着它。

策略里**没有**"自测开关"：学生端面板本来就没有「测试运行」入口 —— 判题一律在主机做、
学生机不装编译器。给一个没有可关之物的开关，老师关了发现什么都没变，比不给更糟。

### 改测验名称

房间开着的时候也能改名字（「房间设置」的**测验名称** + **改名**按钮），改完立刻广播给
在线学生。**不会把学生踢下线**：房间号与派生密钥都跟标题无关，改名只是换一个显示标签。
这一点在 `ExamSession.rename()` 的注释里写死了原因 —— 哪一天有人"顺手"把标题拼进
`room_secret`，改一次名字就会让全教室同时掉线，而且现象是"改名之后连不上了"，
跟标题看着毫不相干，极难查。

### 房间号即凭据

老师报一个 **6 位数字**房间号，学生输入房间号加自定义用户名就能进，不需要逐人发牌。
房间号本身**绝不明文上线**：握手时发出去的只有它的单向索引
（`sha256(房间号:口令)[:16]`），所以抓包拿不到钥匙。

但它**不是**强凭据，这一点必须说清楚：

| | |
| --- | --- |
| 搜索空间 | 10⁶ |
| 单次口令尝试 | 约 350 ms（scrypt） |
| 在线爆破 | 被"每 IP 每分钟 12 次握手"挡住 |
| **离线爆破** | **抓到一个握手包后，多核并行下是"小时"量级** |

它的定位是"分房间 + 挡住隔壁教室的人"。要抗离线爆破，请由老师另设**房间口令**
（可选，默认留空，**区分大小写**）—— 口令才是加在房间号上面的真实熵。

### 考场身份：账号进场与选手名单（与房间号进场二选一）

建场时选**进场方式**，开房后不再改：

| | 房间号进场 | 账号进场 |
| --- | --- | --- |
| 学生报什么 | 房间号（+可选全场口令）+ 自起用户名 | 名单上的账号 + 个人口令 |
| 名字哪来 | 学生自己起（允许重复） | **老师名单说了算**，学生自报不采信 |
| 适合 | 随堂练习、临时凑场 | 正式考试、要把"谁在用哪台机器"绑进档案 |

- **名单直接在 APP 里建**：主机端「进场方式」选「账号进场」→「名单…」打开编辑器，
  逐行敲账号 / 姓名 / 口令 / 座位；「生成口令」只给没口令的人补（6 位数字、首位非 0，
  照着念不会把前导零念丢）。名单存 `%LOCALAPPDATA%\OfflineOJ\rosters\<名称>.json`，
  上次用过的自动带回。
- 也认 Excel 导出的 CSV：表头认「学号 / 账号 / 姓名 / 密码」等常见写法，自动认 BOM 与 GBK；
  **没收进来的行逐条说明原因**，绝不静默丢弃。可导出一份带口令的「打印条」考前提早发。
- 账号进场没有用户名可填 —— 界面上连输入框都不出现。开房时名单上还没口令的人会被
  **自动补发**并提示，否则"只要账号就能进场"等于没有密码。
- 明文口令是刻意取舍：老师要打印、要念给学生。这份名单请当试卷保管。
- 一人一机、一机一人：同一账号换设备、同一设备换账号都进不来；断线重连不受影响。
- 抓包同样拿不到凭据：握手上线的仍是单向索引，账号与口令都不明文过网。

### 离场锁屏（防窥屏）

学生要离开座位（上厕所、交草稿纸），点「离开一下」把整个答题区**盖住** ——
题面、代码、榜单全都看不见，邻座凑过来也读不到内容。回来在盖板上输
**个人口令或考场口令**，才能继续写继续看。

- 盖板挡得住窥屏，挡不住全局快捷键：锁定期间提交等入口在代码里再拦一道。
- 主机「名单」页多一列「锁」，谁离开中一目了然；「让 TA 离开一下 / 让 TA 继续 /
  全体盖上 / 全体继续」都在老师手里，**老师解锁不需要口令**。
- 解锁校验在主机做（常数时间比较），**错满 5 次锁死**，只能由老师放行 ——
  防有人拿别人的座位试密码。
- 学生自己只能"盖上"不能"解开"：解锁要主机验过口令才生效；断线重连只会把锁
  捡回来，绝不会因为旧快照替你把锁揭开。

### 身份：设备八位 ID

**身份是设备的八位 ID，用户名只是标签。**

- 字符集是 32 个字符：`0123456789ABCDEFGHJKMNPQRSTVWXYZ` —— 沿用 Crockford Base32 的思路，
  排除容易被误读成 `0`/`1` 的 `I`、`L`、`O`，再排除 `U`。8 位 = 40 bit，同一场里撞号可以忽略；
- **保留 `0` 和 `1`** 是有意的：只有 0/1 在字符集里，"用户把 `O` 打成 `0`" 才能被无歧义地
  纠正回来（`O`→`0`、`I`/`L`→`1`、`U`→`V`）。反过来做就会把一个合法 ID 改坏；
- 首次运行生成，存在 `%LOCALAPPDATA%\OfflineOJ\device.json`，**跨场次固定不变**：
  学生认得出自己那一行，老师照 ID 点名也稳定。这个文件与 `settings.json` 分开，
  「恢复默认设置」不会换掉学生的身份；
- **用户名允许重复**。两个"张三"靠设备 ID 区分 —— 强行要求改名，成本落在学生身上，
  而"班里两个张三"是常态。同一个设备 ID 再次连接会顶掉旧连接（记为一次 `replaced`，
  老师界面上看得到），断线重连因此不需要额外操作。

### 榜单字段

总分榜：名次 · 用户名 · 设备 ID · 总分 · 已解决 · 提交次数 · 总耗时 · 总内存 · 最后提交
单题榜：名次 · 用户名 · 设备 ID · 得分 · 通过 · 结论 · **第几次** · 耗时 · 内存 · 提交时间

得分一律写成 `37/50` 这个形式（NOI 成绩单的写法）—— 满分现在跟着测试点分值走，
光写一个 `37` 读不出它离满分还有多远。

**给分规则对标 NOI：**

- 每个测试点**自带分值**（默认 10 分），得分是**通过的测试点分值之和**，
  每题满分是各点分值之和。三个点 50/30/20 的题，只过第一个是 **50** 分，
  不是"过了 1/3 → 33 分"。想让 5 个点的题满分 100，就把每个点设成 20。
- 同一人同一题取**最高分**的一次计入总分。
- 名次**同分并列**，下一名跳过（1, 1, 3）。同分之间再按耗时 → 内存 → 提交时间排队，
  但那只决定**显示顺序**，不改变名次 —— 最后一级用设备 ID 保证结果稳定
  （用户名可重复，拿它收尾会让排序结果不稳定）。

外接自己的判题器时，只要填了 verdict 与通过数就能用：没有分值信息的那条路会按
通过比例折算，分母取**该题的分值之和**，所以"全对 = 该题满分"在两条路径下都成立。

### 主机端可以看到每个人交的代码

主机端多一页「提交与代码」：逐份列出谁、哪题、第几次、什么结论，点一行就在下面看到
那一份的源码（按提交时的语言着色，带行号）；判定说明单独一页签 —— **编译错误的原文
就在那里**，是漏了分号还是类型不对，一眼看得出来。代码可以一键复制，讲评时直接粘进稿子。

**学生端只有榜单，没有这一页。** 这不是靠界面藏起来的：源码在 `Submission.to_dict()`
里就被排除了，出站载荷里从来没有 `code` 这个字段 —— 学生机上没有别人的源码可解，
抓包也抓不到。主机端能看，是因为 `ExamServer` 在自己内存里持有完整的提交记录；
房间一关，这些代码随之丢弃，下一场是另一批学生。

这一页唯一的难点是**刷新不能打断阅读**：提交列表每几秒就会因为别人的提交而重排，
而重填表格会把选中清掉、把滚动位置打回开头。所以选中跟着**提交编号**走而不是行号，
内容没变时连重填都不做 —— 老师正看着第 80 行，视图不会自己跳回第 1 行。

### 时限、收卷与重复提交

- **测验时长**可设为 0～600 分钟，**设为 0 表示不限时**（练习模式常用）；
- **到点强制收卷**（开关）：开启后到点主机广播收卷指令，学生端把编辑器里当前的代码
  **自动交一次**并**锁定编辑器**，这条提交在榜上标为"自动"，老师事后分得清哪几份是系统替交的。
  **关闭时只提醒、不替学生交** —— 收不收是老师的决定，程序不越权；
- **提前收卷**：不等时间到就让全体立刻交卷（现场临时有事）；
- **提前结束测验**：把截止时刻改到现在，收卷 → 停止接受提交 → 宽限走完后统一放榜；
- **允许重复提交**（开关）：关闭后同一题只收第一份；开启时榜上标出**第几次**提交。
  截止后仍有 30 秒**收卷宽限**，网络晚到 0.2 秒的卷子不会被判成迟到。

### 怎么用

1. 老师端：切到「局域网测验 → 主机端」，选模式、勾题目、设时长与收卷策略，点「开启房间」；
2. 把大字的「房间号 + 地址 + 端口」报给学生（点「复制房间信息」可以直接粘到班群里）；
3. 学生端：切到「学生端 · 加入房间」，填地址、端口、房间号、自己的名字，点「加入房间」；
4. 老师端可以随时切回「主机端」看总分榜 / 单题榜 / 提交与代码 / 名单 / 现场记录 / 历史场次。

> **同一台机器可以随时切换角色**：老师在开考前用学生端自己试一次，不占第二台机器。

#### 主机端左列会跟着房间状态换一副面孔

开房前后，老师要做的事完全不同，所以左列**整块换页**，而不是把用不上的设置灰在屏幕上：

| | 左边这一列显示什么 |
|---|---|
| **没开房**（准备视图） | 房间设置 / 本场限制 / 本场题目三组，外加「开启房间」 |
| **开了房**（监考视图） | 大字房间号 + **只读摘要**（模式、进场方式、时长、题数、本场限制）+ 改名 + 开始考试 / 提前收卷 / 提前结束 / 复制房间信息 / 关闭房间 |

只读摘要用的是**当前生效的值**（服务端实际定下来的那份），不是输入框里可能被改过的草稿。

**开房后唯一还能改的是名称**（它只是个显示标签，改它不踢人，房间号与连接都不受影响）；
其他设置一件都动不了 —— 动了会踢人、会让榜单上的数字不可比。想改就关房重开。

> **防火墙**：首次开启房间时 Windows 会弹出"是否允许此应用通过防火墙"，选**允许专用网络**。
> 学生连不上时，先确认两台机器在同一局域网、老师那台没被防火墙挡住、端口填的是同一个。

### 测验结束后的存档（「历史场次」页）

**关房即自动留档。** 一场测验结束后，整场会被写进一个独立目录：

```
%LOCALAPPDATA%\OfflineOJ\exams\20260919-173045-期末模拟\
    session.json        标题 / 模式 / 策略 / 时间 / 参与者 / 题目快照
    submissions.jsonl   全部提交（一行一份）
    leaderboard.json    收卷时的榜单快照
    archive.json        版本号 + 各文件校验和
```

主机端「历史场次」页按时间倒序列出所有档案，可以**打开回看**（复用「提交与代码」页，
页顶会写明这是哪一场）、**打开所在目录**、**删除**。

几条刻意的设计：

- **题目存的是快照，不是 ID。** 题目日后被改动或删掉，档案还得说得出当时学生看到的是什么；
- **目录名第二段是标题，不是房间号。** 房间号是凭据，没有理由撒进文件名；
- **写盘先写 `.partial` 再改名。** 中途崩了不会留下半个档案被当成正常的；列表页跳过 `.partial`；
- **留档绝不拦下关房。** 磁盘满、文件被占用都只记一条日志 —— 下课关房这个动作不能被存档拖住；
- **只存成绩不存代码**：设置里可关。开启时抠源码做两道闸（生成时抠一遍、落盘前再抠一遍），
  验的是"哪一天有人忘了"；关掉之后档案里一个源码字节都不留，同时导出与雷同检测也随之不可用；
- **读档要耐坏**：未知字段忽略、半行坏行跳过并计数、校验和对不上照样能看但要标出来。
  `session.json` 缺失才算真读不出来；
- **删除前先确认，并且校验 `archive.json` 存在**才动手，免得误删别人。

> **隐私提示**：默认留档**包含全部学生源码**。共用一台教师机的场景下，
> 建议在「高级设置」里关掉"连源码一起留档"。

### 雷同检测（考中、考后都能跑）

入口在主机端 **「提交与代码」页顶部的「雷同检测…」**。这一页的数据源同时覆盖
"进行中的这一场"和"打开的档案"，所以刚考完想查一下、和翻出去年那场复查，
用的是同一个按钮。

结果分两层报，**可信度差着量级**，界面上也分开写：

| 层 | 判据 | 能当证据吗 |
|---|---|---|
| **完全重复** | 去掉注释、行首尾空白、空行之后**逐字节**相同 | 可以：这是确定性的 |
| **高度相似** | 词法归一化 + k-gram 指纹 + Jaccard（改了名字也躲不掉）| **不可以**：只是线索，必须人工复核 |

选中结果里的任意一行，下方立刻给出**并排 diff**（注释行不参与比对，
缩进保留 —— 去掉缩进两段代码摆在一起就读不懂了）。

几条口径，都写在报告里：

- **只在同一道题内比较**：两道题都写 `for` 循环不算相似；
- **每人每题只取一次**（最高分，同分取更晚的那次），报告写明合并了多少份；
- **自动扣除本题的公共模板**（`#include <bits/stdc++.h>`、快读那段……）。
  不扣的话第一版报告会全是 90%+；
- **样本太少时不扣**（不足 3 份）：两份提交里"两份都有"的公开度是 100%，
  照比例扣会把两人**真正共享**的那段一起扣掉 —— 抄的人被判 0 是最坏的一种错。
  这时报告会明说"这几道题没能扣除公共模板，请只当作线索"；
- 界面里**不出现"抄袭"**字样，顶部常驻一条提示：相似度是线索不是结论，
  同一道题的正确解法本来就容易写得像。

**隐私前提**：如果留档时关掉了"连源码一起留档"，档案里没有代码，这一项就无从比。
进行中的这一场不受影响（源码在主机内存里）。

**性能**：400 份提交 / 1.3 万行约 2 秒；200 份"全班互抄"0.4 秒。
纯 Python（倒排索引只比至少共享一个指纹的那些对），没有引入任何原生模块。

<br>

## 命令行用法

```powershell
python -m offline_oj.cli list
python -m offline_oj.cli detect --save
python -m offline_oj.cli judge P0001 solution.cpp --language cpp
python -m offline_oj.cli export .\backup.zip
python -m offline_oj.cli import .\backup.zip --strategy rename
python -m offline_oj.cli doctor
```

退出码：`0` 通过 · `2` 未通过 · `1` 出错。

判题方式跟着题目走：`judge` 一道配有文件输入输出或校验器的题目时，命令行的行为与界面一致，  
缺失的工具链也会被自动检测（包括校验器要用的那一种）。

## 数据位置

| 内容    | 路径                                                   |
| ----- | ---------------------------------------------------- |
| 题库    | `%LOCALAPPDATA%\OfflineOJ\problems.json`             |
| 设置    | `%LOCALAPPDATA%\OfflineOJ\settings.json`             |
| 设备标识  | `%LOCALAPPDATA%\OfflineOJ\device.json`               |
| 题目图片  | `%LOCALAPPDATA%\OfflineOJ\problem_resources\`        |
| 日志    | `%LOCALAPPDATA%\OfflineOJ\logs\app.log`              |
| 提交历史  | `%LOCALAPPDATA%\OfflineOJ\submissions\history.jsonl`（本机单人练习，上限 500 条） |
| 测验档案  | `%LOCALAPPDATA%\OfflineOJ\exams\<日期>-<标题>\`（关房时自动留档，见下） |
| 编译工作区 | `%LOCALAPPDATA%\OfflineOJ\workspace\`（启动时自动清理）       |

`device.json` 是局域网测验用的设备标识（八位 ID），与 `settings.json` **刻意分开**：
「恢复默认设置」不该把学生的身份换掉。

设置环境变量 `OFFLINE_OJ_HOME` 可覆盖数据根目录，用于测试隔离或便携部署。


## 测试

```powershell
# 全部单元测试（离屏运行，无需桌面环境；项数以 --collect-only 为准，不写死在文档里）
python -m pytest tests -q
# 或
python -m unittest discover -s tests -v

# 上面这条会连"界面重复显示"的守卫一起跑：同一条命令只有一处入口（没有第二
# 条全局工具栏）、同一个字符串不在一屏上出现两次
python -m pytest tests\test_ui_dedup.py -q

# 只跑代码编辑器 / 补全相关（tests\test_completion.py）
python -m pytest tests\test_completion.py -q

# 代码编辑器的信号契约：换主题不该被当成"改过内容"，换主题也不该漏高亮器
python -m pytest tests\test_code_editor.py -q

# 界面主题与打磨：徽标对比度、语义角色、内联主题色、间距令牌、行尾
python -m pytest tests\test_ui_theme.py -q

# 快捷方式写入器（手写 MS-SHLLINK，绕开本机对 COM 的限制）
python -m pytest tests\test_shortcut.py -q

# O2 优化开关：勾选值是否真的走到编译参数、提交与自测是否用同一套参数
python -m pytest tests\test_o2_option.py -q

# 命令面一致性：同一个键序列不许绑两次
python -m pytest tests\test_ui_shortcuts.py -q

# 局域网测验：房间号握手、双模式放榜、收卷、设备 ID 身份、身份持久化
python -m pytest tests\test_net_session.py tests\test_net_lan.py `
                 tests\test_net_protocol.py tests\test_net_crypto.py `
                 tests\test_net_identity.py -q

# 测试点分值：写题面板的数字框 → 题目满分 → 落盘读回（含"默认 10 分不写盘"）
python -m pytest tests\test_point_values.py -q

# 主机端房间设置：改名 / 不放榜 / 本场限制（语言与功能开关），界面接线 + 学生端落地
python -m pytest tests\test_exam_settings.py -q

# 考场身份与管控：名单数据层与 CSV（test_roster）+ 进场方式 / 名单 / 离场锁屏的界面全链
python -m pytest tests\test_roster.py tests\test_exam_access.py -q

# 主机端「提交与代码」页：列表与源码联动、刷新不打断阅读、关房间即丢弃
python -m pytest tests\test_exam_code_view.py -q

# 测验档案（写/读/列/删）：目录名净化、校验和、坏行跳过、隐私两闸、删除防误删
python -m pytest tests\test_records.py -q

# 雷同检测：词法（注释/字符串/缩进归一）、完全重复判定、骨架扣除与小样本取舍、
# 指纹与相似度、报告口径；界面侧（结果对话框 + 「提交与代码」页的入口）
python -m pytest tests\test_similarity.py tests\test_similarity_ui.py -q

# 界面冒烟测试：离屏构建主窗口、遍历所有面板，含局域网测验与考场管控的端到端两趟
# （开房 → 真客户端进场 → 提交 → 判定 → 榜单 → 收卷；APP 内建名单 → 账号进场 →
#  离场锁屏 → 老师解锁 → 错口令被拒 → 留档），可选截图
python tests\smoke_gui.py --shot build\screens
python tests\smoke_gui.py --shot build\screens-dark --theme dark

# 局域网测验真实 TCP 验收：按老师上课的顺序把整条链路跑一遍，每步都有证据
# 判题默认走替身（无编译器也能跑），加 --real 则让真实工具链编译执行一遍
python tools\lan_e2e.py
python tools\lan_e2e.py --real

# 编辑器观感截图（需要真实桌面，会短暂弹出窗口）
python tools\shot_editor.py

# 端到端真实判题（自动检测工具链后跑 Python / Java / C / C++）
python tools\e2e_judge.py

# 判题方式验收：文件输入输出 + C++ 编写的校验器（现场编译），含导出导入往返
# 会验证"同一份解，精确比对判 WA、挂上校验器判 AC"这类对照关系
python tools\e2e_judge_config.py

# MSVC 端到端验收：内置「两数求和」语料走 cl.exe，与 GCC 基线逐条比对判定
python tools\msvc_e2e.py -v

# 反复跑同一组用例，暴露偶发崩溃 / 挂死
#（排查"单独跑全过、凑到一起必崩"这类问题；会区分崩溃与超时，并打印崩溃块开头）
python tools\pytest_stability.py tests --repeat 5
python tools\pytest_stability.py tests\test_completion.py::TestPopupLifetime tests\test_core.py --repeat 3

# 带看门狗地跑任何脚本：N 秒后把**所有线程的调用栈**倒出来。
# 挂死最难查的地方是"看起来像还在跑"，全线程栈能直接指出谁在等谁。
python tools\watchdog_run.py --after 45 tests\smoke_gui.py
python tools\watchdog_run.py --after 45 --module pytest tests -q     # 也能包 pytest（等价 -m）
python tools\watchdog_run.py --after 30 --every 20 --hard-exit 120 tools\lan_e2e.py

# 冻结版产物验收：PE 头/子系统、九帧图标、版本资源、清单、面板是否全在里面
python tools\verify_frozen.py
python tools\verify_frozen.py --launch      # 追加：全新数据目录起两次，验单实例唤出

# 在桌面 / 指定目录建 .lnk 快捷方式（手写 MS-SHLLINK，不需要 COM / pywin32）
python tools\make_shortcut.py                       # 桌面建 OfflineOJ.lnk
python tools\make_shortcut.py --into build --name 测试
python tools\make_shortcut.py --read "%USERPROFILE%\Desktop\OfflineOJ.lnk"

# 未使用导入检查
python tools\lint_unused_imports.py

# 内联主题色检查：控件自己的 setStyleSheet 不会随主题重刷，切深色后会留在浅色
python tools\lint_inline_palette.py
```

测试覆盖输出比对、题库原子落盘与备份、导入导出往返、ZIP 目录穿越防护、  
设置损坏隔离、编辑器补全（前缀提取、候选来源、片段展开、语言切换、弹窗列表列数）、  
编辑器的信号契约（**语法高亮会让 `textChanged` 响**，所以"用户改了内容"必须看
`contentsChange`）、换主题不泄漏高亮器、  
界面主题（徽标前景色的 WCAG 对比度、语义角色齐备、无内联主题色、间距令牌、源码行尾）、  
工具链跨盘符扫描与编译标准降级、MSVC 环境组装与标准参数选择、  
可选依赖的导入时机，判题配置的向后兼容与校验器协议（AC / WA / PE / IE / 超时）、  
文件模式（含"上一轮残留的输出文件不能当本轮答案"），  
编译优化开关的传递（勾选 → 编译参数；提交与「测试运行」用同一套参数；考试主机端一致），  
界面命令面的一致性（同一个键序列不许绑两次、没有第二条全局工具栏），  
CCF CSP 规约的题目英文名与文件命名（含非法字符清洗、按题目 ID 兜底、`{name}` 模板展开），  
局域网测验的房间号归一化与单向索引、设备八位 ID 的生成/归一化/跨场次持久化、
双模式的放榜时机、重复提交计数、收卷宽限与强制收卷、
NOI 式的给分与名次（逐测试点分值之和、同分并列且下一名跳过、同分只比出显示顺序）、
**源码只上行不下发**（主机内存里有全套，出站载荷里一个字节都没有）、
主机端读源码页的刷新不打断（选中跟提交编号走、内容没变不重灌、关房间即丢弃），  
以及真实执行评测的端到端流程。

> **写界面测试时的一条经验**：`QPlainTextEdit.textChanged` 背后是
> `QTextDocument.contentsChanged`，而**语法高亮重排也会触发它** —— 于是"换主题"
> 与"改正文"在监听者眼里完全等价。这类信号只能靠 `contentsChange(pos, removed, added)`
> 区分（重排格式时它根本不发）。代价很实在：存题面板被标成"有未保存的修改"，
> 关窗口时弹出保存确认，而离屏冒烟测试里没人点得到那个模态框，整轮挂死 4 小时 33 分。

> **写测试时的一条经验**：查询"某个导入有没有副作用"这类问题必须在  
> **独立子进程**里做。主测试进程可能已经被别的用例导入过目标模块，  
> 直接查 `sys.modules` 会得到假阴性 —— `test_sandbox_does_not_import_psutil_at_module_level`  
> 就是为此写成子进程的。


### 界面上这些约定是刻意的

界面改动容易越改越乱，所以下面几条当成硬规矩，`tests\test_ui_shortcuts.py` 与
`tests\test_ui_theme.py` 会替我们记着：

- **命令入口只有两层：菜单（唯一真源）+ 面板就地按钮，没有全局工具栏。**
  加按钮之前先看菜单里有没有同键入口 —— 有的话就是纯重复。这里踩过一次弯路：
  最早是一条平铺 7 个按钮的工具栏，后来按选项卡把按钮收起（帮助页整条不显示），
  看着"每页只剩两三个"就收工了 —— 但那两三个仍然和面板自己的按钮重复，
  写题页一屏上能同时看到两个"提交代码"、编译器页一个叫"验证工具链"一个叫
  "验证可用性"。最后整条工具栏删掉了：它不携带任何自己的信息。
- **同一屏上同一个字符串只许出现一次。** 题库统计原来在状态栏和题目列表下各显示
  一份（一字不差），"当前 N 个测试点"和"测试点数"是同一个数字，
  `PathPicker` 的空输入框占位符和右侧状态徽标都写着"未配置"。判断标准很简单：
  两个地方同时显示同一件事时，用户要花时间确认"它们是不是一样的"，
  而这份确认永远没有收益。留信息量更大或位置更顺的那个。
- **同一个键序列只许绑一次。** 菜单里一条窗口级 `QAction`、面板里再一条 `QShortcut`，
  就是同一个键绑两次，Qt 会报 `Ambiguous shortcut overload`，按下去哪个生效看运气 ——
  用户只会觉得"这个键有时候管用"。测试直接扫 `QAction` + `QShortcut` 查重复。
- **矮的那一栏不要硬撑高。** 两栏并排时 QGroupBox 会被拉到和高的那栏齐平，
  于是内容少的那栏框里空出一大片灰底，看着像"该有东西没加载出来"。
  内容少的那个外面包一层纵向布局 + `addStretch`，让留白落在框外。
- **换主题只许改显示，不许改数据状态**，间距/边距一律用 `theme.py` 里的令牌，
  不许内联主题色（见上面的测试覆盖）。
- **界面走查要看真机截图**：离屏环境没有中文字体，截图里标题正文全是方框，
  只能看布局矩形 —— 拿那种图"看界面"等于没看。要读界面就
  `QT_QPA_PLATFORM=windows python tests\smoke_gui.py --shot build\screens-real`。
  截图默认是 2880×1760（DPI 缩放），想看某个控件的细节别用缩略图下结论，
  先按比例裁下来 1:1 看，否则会把正常的三角箭头误判成"不可见的糊块"。

### GUI 对象的销毁时机（两个真机上抓到的崩溃）

PySide6 里"Python 对象被回收"和"Qt 对象被析构"是两件事，踩过两次：

- **无父对象的弹出列表会活得比宿主久。** `QCompleter` 自己建的候选列表默认是  
  没有父对象的顶层窗口，编辑器连同它持有的 completer 销毁之后，那个窗口还在桌面上，  
  延迟到达的绘制事件会打到已失效的委托上 —— 表现为  
  `Windows fatal exception: access violation`，崩溃点在 `CompletionDelegate.paint`，  
  跟代码毫无逻辑关联。修法是显式让编辑器当父对象  
  （`CodeCompleter.__init__` 里的 `popup.setParent(editor, Qt.Popup)`），  
  Qt::Popup 的定位仍由 QCompleter 按全局坐标完成，与 `QComboBox` 的下拉是同一套机制。
- **GC 在哪个线程跑，控件就在哪个线程被析构。** 判题时每条测试数据都会新起一条  
  `oj-monitor` 监控线程采样子进程内存。它第一次碰到惰性 `import psutil` 时，  
  导入过程的分配会触发一轮**全量回收**，而那一轮回收发生在监控线程上 ——  
  于是顺手把别处留下的、已不可达的 PySide 控件也在监控线程里析构了。  
  Qt 要求 `QWidget` 只能在 GUI 线程析构，结果是访问违例或者状态被打坏后直接挂死；  
  faulthandler 抓到的最内层帧只有一句 `Garbage-collecting`，往上全是 importlib。  
  两道防线各管一头：`ProcessMonitor` 在**构造时**（而不是监控线程里）就把  
  psutil 能力定下来；`tests/conftest.py` 则在每个用例结束时、仍在 GUI 线程上  
  把本轮垃圾收掉。
  > 这个坑的现场很有欺骗性：单独跑用例全过，凑到一起必炸 ——  
  > 因为崩不崩只取决于"那一轮 GC 手里有没有 GUI 垃圾"。  
  > 定位办法是把 `pytest -v -u -X faulthandler` 的输出留下来看**崩溃块的最前面**，  
  > 尾部只剩 pytest 自己的几帧（`runpy` → `_console_main`），一点用没有。
- **有父对象的弹窗，`geometry()` 是父控件坐标系。** 哪怕它带着 `Qt::Window` 标志、  
  `isWindow()` 也返回 `True`，`geometry()` 依旧是相对父控件的值，跟屏幕坐标差一个  
  父窗口左上角。所以问"弹窗在屏幕哪儿"必须用 `mapToGlobal(QPoint(0, 0))`。  
  这个差异在离屏平台上被掩盖了（窗口位置接近原点，两种算法结果一样），  
  一换到真实桌面就差了 400 像素 —— 而且弹窗位置其实是对的，红的是断言。
  > 还有一条前提：**别在没 `show()` 过的窗口上量位置**。未显示的顶层窗口，  
  > Qt 给它算出来的全局坐标是虚构的，比较两个虚构的数字没有意义。  
  > `test_popup_sits_under_the_caret` 因此会真的把编辑器显示出来 ——  
  > 两个点取自同一窗口，窗口被系统摆在哪里都不影响结论。

### 工具链探测与编译标准

三个曾经真实存在的坑，都已修好并留了回归用例：

- **候选目录不写死盘符**。目录模板统一用 `{drive}` 占位，运行时用  
  `GetLogicalDrives` + `GetDriveTypeW == DRIVE_FIXED` 枚举所有固定盘再展开。  
  早期写死 `C:\...`，导致装在 D 盘的 Dev-Cpp / MinGW **完全探测不到**。
- **C/C++ 标准参数按编译器能力降级**。候选阶梯是  
  `-std=c++17 → c++14 → c++11 → 不加参数`（C 是 `c11 → c99 → c90 → 不加`），  
  只在失败信息确实和该参数有关时才降级（语法错误立刻返回，不白编译三次），  
  并在同一进程内缓存实测可用的那个。原因是 Dev-Cpp 自带的 TDM-GCC 4.9  
  **不认 `-std=c++17`**，写死会让那类机器上每一份 C++ 提交都编译失败，  
  而且报错长得像用户代码的问题。
- **编译器选择 GCC 优先于 MSVC**。不是按版本号排大小 —— 这台机器的 `cl.exe` 是  
  19.44、Dev-Cpp 的 `g++` 是 4.9，按版本排会让 MSVC 胜出。但对刷题工具这是错的：  
  题解与在线评测都以 GCC 为准（见下面 MSVC 一节的两条实测差异）。  
  所以家族优先级先比，版本号后比。


### MSVC（`cl.exe`）支持

`cl.exe` 不是普通的独立编译器，它靠 `INCLUDE` / `LIB` / `PATH` 找标准库 ——
**没有这套环境时连 `#include <iostream>` 都过不了**（报 `C1034`），
光把路径填进配置是没用的。

官方初始化方式是跑 `vcvars64.bat`，但那条路对宿主程序非常不友好：

1. 它是批处理，必须经 `cmd.exe` 执行；而 `subprocess` 的 `list2cmdline` 会把内层
   引号转义成 `\"`，**`cmd.exe` 不认反斜杠转义**，于是带空格的
   `"C:\Program Files\..."` 直接失败（`不是内部或外部命令`）。`shell=True` 也救不了。
2. 唯一可行的写法是临时写一个无空格路径的包装 `.bat` —— 能跑，但**单次要 100 秒左右**，
   因为 `vcvars` 内部大量调用 `reg.exe` 探测 SDK / .NET / 旧工具集版本。

所以改为自己拼：`vswhere.exe` 定位 VS 安装根 → `winreg` 读 `KitsRoot10` →
按 VS 2015 以来非常稳定的目录布局算出三个变量。**实测 1.9 ms**，不经过 `cmd.exe`，
也不依赖 `reg.exe`。正确性由"真的编译一个程序并运行它"兜底。

编译参数上有几处必须和 GCC 对齐，否则会出现"能过的代码在这台机器上编不过"：

| 参数 | 作用 | 不加的后果 |
|---|---|---|
| `/utf-8` | 显式声明源码与执行字符集都是 UTF-8 | 按系统 ANSI 代码页（中文 Windows 是 936）读源码。`L"中文"` 直接编错，遇到无法映射的字节还会刷 `C4819` |
| `/EHsc` | 标准 C++ 异常语义（只对 C++ 加） | GCC 默认就开，不写会让依赖异常的代码行为不一致 |
| `/MT` | 静态链接 C 运行库 | `/MD` 引入 `vcruntime140.dll` 依赖，换台机器就报找不到 DLL |
| `/W3` | 对应 `-Wall` 的告警级别 | `/Wall` 会把系统头文件的每条提示都倒出来，噪音过大 |
| 相对文件名 | 源文件与产物都用相对名（`cwd` 已设为工作目录） | 工作目录在 `%LOCALAPPDATA%` 下，用户名带空格时 `/Fe:C:\Users\John Doe\...` 会被 cl 的参数解析绊住 |

**标准参数不能照搬 GCC 那套"失败就降级"**：MSVC 对不认识的 `/std:` 不报错，
只发一条 `warning D9002`，**退出码仍是 0**，然后**静默按默认标准编译**
（实测 `/std:c++23` 编出来的 `_MSVC_LANG` 是 201402，即 C++14）。
也就是说"编过了"完全不能证明这个标准被接受了，靠失败来探测行不通。
所以改成按工具集版本号直接选：

| MSVC 版本 | C++ | C |
|---|---|---|
| ≥ 19.14（VS2017 15.7） | `/std:c++17` | — |
| ≥ 19.30（VS2022） | `/std:c++17` | `/std:c17` |
| ≥ 19.27（VS2019 16.7） | `/std:c++17` | `/std:c11` |
| ≥ 19.00（VS2015 Update 3） | `/std:c++14` | 不加 |
| 更早 | 不加 | 不加 |

阶梯末尾永远留一个"不加参数"的兜底；万一某个版本确实忽略了它（`D9002` 会被
解析出来），下一轮就退到无参数并把结果写进缓存，不会每次都带一条注定被忽略的参数。

顺带修掉一个本地化相关的 bug：`cl.exe` 的版本横幅**随系统语言变化** ——
中文 Windows 上打出来是「用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.44.35228 版」，
而且走的是 **stderr**（stdout 里放的是用法说明，非空）。
早期写成 `stdout or stderr` 再匹配英文 `Version\s+...`，结果**永远解析出版本号为空**。
现在两个流都看、中英文形态都匹配；目标架构干脆不从横幅读，直接从
`bin\Hostx64\x64\cl.exe` 的父目录名取，天然与语言无关。

### 打包产物的验证方式

源码能跑不代表打包产物能跑，因此发布前建议按这套流程验一遍：

```powershell
# 一条命令跑完全部静态项 + 双实例运行验收
python tools\verify_frozen.py --launch
```

它会检查：

- **PE 头**：x64 / PE32+ / 子系统为 2（GUI，双击不弹控制台）/ DllCharacteristics 位；
- **九帧图标**：把 `assets\oj_icon.ico` 里每一帧的图像数据拿去 exe 里逐个找 ——
  这条是真会坏的，`PIL.Image.save(sizes=[...])` 那种写法只写进一帧，任务栏图标发糊，
  而且**不报错**；
- **版本资源**（UTF-16LE）与**清单**（PerMonitorV2 / longPathAware / asInvoker / Common-Controls）；
- **关键模块是否都在里面**：面板清单是**扫目录**得来的，因为一旦有人图省事写成
  `importlib.import_module(f".{name}")`，源码照跑，冻结版会**整个少掉几个选项卡**；
- **`--launch`**：用全新数据目录起两次进程（离屏，不弹窗）—— 第一次应停在事件循环，
  第二次应在 1 秒内唤出已有窗口后自行退出，最后查日志里 ERROR 为 0。

手动等价做法（不想用工具时）：

```powershell
$env:QT_QPA_PLATFORM = "offscreen"
$env:OFFLINE_OJ_HOME = "$PWD\build\frozen-check"
.\dist\OfflineOJ\OfflineOJ.exe        # 第一次：应进入事件循环
.\dist\OfflineOJ\OfflineOJ.exe        # 第二次：应 1 秒内唤出已有窗口并退出
Get-Content .\build\frozen-check\logs\app.log
```

桌面快捷方式用 `python tools\make_shortcut.py` 建（默认建到桌面，指向
`dist\OfflineOJ\OfflineOJ.exe`，工作目录与图标都指对）。

> **为什么是手写二进制而不是调 COM。** 常规做法是
> `New-Object -ComObject WScript.Shell`，但本机安全策略禁止 COM 实例化
> （理由是"COM 可以执行任意代码"），环境里也没有 `pywin32`。`.lnk` 用的
> **MS-SHLLINK** 是公开的固定格式，直接写反而更可控：零依赖、换台机器也能用。
>
> 写的时候踩了一个**不报错**的坑：第一版只写了 `LinkInfo`（里面已有完整路径），
> 省掉了 `LinkTargetIDList`，结果 `startfile` 直接报 `WinError 1155`（没有关联）——
> 系统压根不认这个文件是快捷方式。**别猜规范，去看真货**：把资源管理器自己写的
> `Steam.lnk` / `OneDrive.lnk` / `爱奇艺.lnk` 拆开，三个都带着 200~430 字节的这段。
> PIDL 的格式（逐级 item ID）手写不现实，所以用 `SHParseDisplayName`
> （普通 Win32 函数，不是 COM）让 shell 自己拼。顺带量到一条约定：
> `CountCharacters` **不含**结尾的 NUL（`'Steam'` 是 5 不是 6）——
> 含进去也不会立刻报错，只会让后面几段字符串整体错位。
>
> 验完别忘了**让系统自己解析一次**：`os.startfile(lnk)` 起得来、
> 目标进程把日志建出来，才算真的能用。拿自己写的解析器回读是循环论证。


## 已知限制

- **MSVC (`cl.exe`) 需要 Visual Studio 或 Build Tools，且判题结论与在线评测有两条实测差异**。
  定位与环境组装已实测通过（VS 2022 Community / MSVC 19.44，组装 1.9 ms），
  `tools\msvc_e2e.py` 用同一套 P0001 语料比对过判定。两条差异都源于 MSVC 与 GCC 的
  语义差别，**不是本程序的缺陷**，也无法通过编译参数消掉：

  | 语料 | GCC（在线评测一致） | MSVC | 说明 |
  |---|---|---|---|
  | `#include <bits/stdc++.h>` | 编过 | CE | MSVC 不提供这个 GCC 专有头文件 |
  | `void main()` | CE | **AC** | MSVC 对 `void main` **连警告都不发**，`/W4`、`/WX`、`/we4326` 一律放过，没有可提级的警告码 |

  因此工具链选择器**把 GCC 排在 MSVC 前面**：只有在机器上没装 GCC 系编译器时才会退回 MSVC。
  想复现"`void main` 判 CE"这类在线评测行为，必须用 GCC。

- **安全软件可能拦下编译产物**。装了 360 / 火绒 / 联想电脑管家这类软件时，
  刚编译出来的可执行文件会被拦截执行、随后连文件一起删掉，表现为
  `[WinError 5] 拒绝访问`，下一次再试又变成"找不到文件"。
  实测特征是：**产物编译出来放着不动一直在，一尝试启动就消失**。
  本程序会在这种情况下给出带具体目录的提示，引导把工作目录加入信任区
  （360：安全防护中心 → 信任区；火绒：设置 → 信任区 → 添加目录）。
  注意这是**逐文件**的行为：同一个目录里可能有的产物能跑、有的被拦。

- **C / C++ 判题已在 TDM-GCC 4.9.2 上实测通过** —— 8/8 测试点 AC，
  并验证了 `void main` → CE、多输出一行 → WA。MSVC 已按上表实测，clang 仍待补测。

- **输出上限是"读完再截断"，不是"读到上限就停"。** `MAX_OUTPUT_BYTES`（4 MB）
  约束的是**判题结论**和**交给学生看的信息**；子进程的输出实际由
  `subprocess.communicate()` **一次性读进判题进程的内存**，截断发生在那之后。
  于是"死循环打印"的选手程序在判成 OLE 之前，会先把那段输出搬进主机内存
  （实测 300 MB 量级）。装了 psutil 时，题目的内存上限监控会杀掉子进程兜底，
  但**读取线程本身不受那个上限约束**。要连峰值一起压住，得把读取改成
  "分块读 + 累计到上限即停并杀进程"（见 `core/sandbox.py` 的 `wait()`）。
  目前这是**已知且记录在案**的行为，不是待修的紧急项：判题结论是对的，
  受影响的是主机的内存峰值。

- **校验器只支持 C++ 与 Python，且跟着题目源码走**。把带校验器的题目导出给别的机器时，
  对方必须装有对应语言的工具链，否则整题会判 IE（评测机内部错误）而不是误算到选手头上。
  C 与 Java 不提供 —— 校验器主要在处理字符串与空白，这两种写起来远比 C++ / Python 费事。

- **校验器跑在判题机上，与选手程序同等权限**。它没有额外的沙箱，只是有 30 秒 / 1 GB 的
  上限；而且它是由**出题人**写的，导入别人给的题目包等于运行对方的代码。这条与
  「提交的代码同样在本机运行」是同一类边界，见下方「安全边界」。

- **文件模式只在工作目录内读写**。题目只能指定文件名（`JudgeConfig.problems()` 会拒绝
  含路径分隔符或 `:*?"<>|` 的名字），程序无法用它把文件写到试题目录之外。

- **安装程序需另行编译** —— `packaging/installer.iss` 已写好，但本机未安装
  [Inno Setup 6](https://jrsoftware.org/isdl.php)，执行
  `pwsh packaging\build.ps1 -Installer` 前需先安装它。绿色版 `dist\OfflineOJ\` 可直接使用，
  便携版 `pwsh packaging\build.ps1 -Portable` 会产出 ZIP，都不依赖 Inno Setup。

## 安全边界

评测在**本机以当前用户权限**运行提交的代码，没有虚拟机或容器级隔离。
「设置 → 判题选项 → 提交前检查危险操作」只是一道提示性护栏，不能阻止恶意代码。
请只运行自己信任的代码。

**自定义校验器属于同一类边界**：它就是一段普通程序，由出题人提供、随题目包一起分发，
导入来源不明的题目包再判题，等同于运行包内附带的代码。这条不会因为
"校验器由本程序编译"而变安全 —— 编译的正是对方给的源码。

**局域网测验的安全性建立在一个架构选择上**：判题在主机的机器上做，**测试数据从不下发**。
下发给学生端的只有样例测试点（没有一道题被标记为样例时，样例就是空的 —— 宁可学生看不到
样例，也不能因为"找不到样例就退而求其次发全部"而把正式测试点漏出去）。
这一条比任何传输加密都更根本：数据没出去，就没有"被破解"这回事。

在此之上，握手与提交走 ChaCha20-Poly1305 加密帧，房间号从不明文上线。
但要清楚**房间号不是强凭据**：6 位数字的搜索空间是 10⁶，离线爆破在抓到一个握手包之后
是"小时"量级。它的定位是"分房间 + 挡住隔壁教室的人"；要抗离线爆破，请另设房间口令。
完整的威胁边界见上方「局域网测验 → 房间号即凭据」。
