DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,483 @@
|
||||
// Command pvfcompare 独立比对两个 PVF 归档,用于验收另一套(C#)PVF 写回/重打包实现。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/scratch/pvfcompare -a <pvfA> -b <pvfB> [-limit N] [-max-examples 20] [-quiet]
|
||||
//
|
||||
// 退出码:0 = 完全一致;1 = 存在差异;2 = 参数/IO 错误;3 = 因 -limit 只比对了部分内容且未发现差异。
|
||||
//
|
||||
// # 为什么这份输出算「独立证据」
|
||||
//
|
||||
// 本工具只通过本仓库 internal/platform/pvf 的 Go 读取实现取得事实:
|
||||
// Open / Format / Snapshot / Files / FileCount / RangePaths / FindFile / FindFileIndex / FileRaw。
|
||||
// 被验收的写回实现(C#)的字段布局、压缩、比对逻辑一概不参与,也没有被参考,
|
||||
// 因此这里的结论不依赖被验收方的自述。
|
||||
//
|
||||
// # 为什么「解压后原始字节」相等就足以证明内容一致
|
||||
//
|
||||
// PVF 的 body 是按 chunk 压缩存储的:同一份文件内容在不同归档里可能落在不同
|
||||
// chunk、不同偏移,压缩等级、chunk 边界、重打包顺序都会让「.pvf 文件本身的字节」
|
||||
// 和「压缩流」发生变化。所以容器字节或压缩流都不能作为内容一致性的依据。
|
||||
// 读取侧还原出的文件原始字节是归档语义上真正承载内容的部分,它与 chunk 划分和
|
||||
// 压缩方式无关:逐文件字节相等 <=> 内容相等。
|
||||
// 另外 read.go 的 readRawIndex 按 item.dataSize 精确切出字节并复制返回,
|
||||
// 所以 len(raw) 就是文件表声明的大小;工具再单独比较 Size 字段,等于交叉校验了一次。
|
||||
//
|
||||
// # 性能取向(避免对同一 chunk 反复解密解压)
|
||||
//
|
||||
// 逐文件比对按 A 的 File.Index 顺序遍历文件表:同一 chunk 内的文件在表里通常相邻,
|
||||
// 顺序读能让 chunk LRU 持续命中,而不是每个文件都重新解压一次 chunk。
|
||||
// A 侧按索引读取(FileRaw(idx));B 侧先按路径查一次索引,随后同样按索引读取。
|
||||
// 结尾打印两侧 chunk 缓存的命中/未命中/淘汰计数,用来佐证「没有反复解压同一 chunk」。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pathA := flag.String("a", "", "PVF A 路径(基准)")
|
||||
pathB := flag.String("b", "", "PVF B 路径(待比对)")
|
||||
limit := flag.Int("limit", 0, "最多对多少条公共路径做逐文件内容比对(0 = 不限)")
|
||||
maxExamples := flag.Int("max-examples", 20, "每类差异最多列出的示例路径数")
|
||||
quiet := flag.Bool("quiet", false, "只输出最终汇总行")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*pathA) == "" || strings.TrimSpace(*pathB) == "" {
|
||||
fmt.Fprintln(os.Stderr, "用法: pvfcompare -a <pvfA> -b <pvfB> [-limit N] [-max-examples 20] [-quiet]")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(2)
|
||||
}
|
||||
if *limit < 0 || *maxExamples < 0 {
|
||||
fmt.Fprintln(os.Stderr, "-limit 与 -max-examples 不能为负数")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// report 在 -quiet 下全部静默,只留下最后的汇总行。
|
||||
report := func(format string, args ...any) {
|
||||
if *quiet {
|
||||
return
|
||||
}
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
total := time.Now()
|
||||
|
||||
sideA, openA, err := openSide("A", *pathA)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "打开 A 失败: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
sideB, openB, err := openSide("B", *pathB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "打开 B 失败: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// ---------- 1. 容器级 ----------
|
||||
report("=== 1. 容器级 ===\n")
|
||||
printSide(sideA, report)
|
||||
printSide(sideB, report)
|
||||
|
||||
containerDiff := 0
|
||||
if sideA.snapshot.FileCount != sideB.snapshot.FileCount {
|
||||
containerDiff++
|
||||
report(" !! 文件总数不一致: A=%d B=%d (差 %+d)\n",
|
||||
sideA.snapshot.FileCount, sideB.snapshot.FileCount,
|
||||
sideB.snapshot.FileCount-sideA.snapshot.FileCount)
|
||||
}
|
||||
if sideA.snapshot.Format != sideB.snapshot.Format {
|
||||
containerDiff++
|
||||
report(" !! 归档格式不一致: A=%q B=%q\n", sideA.snapshot.Format, sideB.snapshot.Format)
|
||||
}
|
||||
if sideA.snapshot.GroupCount != sideB.snapshot.GroupCount {
|
||||
// 组数只是容器形态:重打包会重新切 chunk,组数变化本身不代表内容变化。
|
||||
report(" -- 组数不同(仅供信息,重打包会改变 chunk 划分,不计入差异判定): A=%d B=%d\n",
|
||||
sideA.snapshot.GroupCount, sideB.snapshot.GroupCount)
|
||||
}
|
||||
if sideA.snapshot.Checksum == sideB.snapshot.Checksum {
|
||||
report(" -- 两个 .pvf 的 SHA-256 完全相同(容器字节级同源)\n")
|
||||
}
|
||||
if containerDiff == 0 {
|
||||
report(" 容器级: 文件总数/格式一致\n")
|
||||
}
|
||||
|
||||
// 归一化路径键少于文件表条目 => 表里有重复(或仅大小写不同)的路径。
|
||||
// 读取层 FindFile/ReadRaw 是「同名取第一条」,这会影响按路径解析 B 侧条目的结果,
|
||||
// 所以必须显式提示,而不是悄悄按第一条比对。
|
||||
for _, side := range []*side{sideA, sideB} {
|
||||
if side.uniquePaths != side.snapshot.FileCount {
|
||||
report(" !! %s 的文件表有 %d 条记录但只有 %d 个唯一归一化路径键(存在重复/大小写冲突路径);按路径查找只会命中第一条\n",
|
||||
side.label, side.snapshot.FileCount, side.uniquePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. 路径集合 ----------
|
||||
pathStart := time.Now()
|
||||
aOnly, bOnly, common := diffPaths(sideA, sideB, *maxExamples)
|
||||
pathElapsed := time.Since(pathStart)
|
||||
|
||||
report("\n=== 2. 路径集合(按读取层归一化后的小写查找键统计) ===\n")
|
||||
report(" A 唯一路径键: %d\n", sideA.uniquePaths)
|
||||
report(" B 唯一路径键: %d\n", sideB.uniquePaths)
|
||||
report(" 共有路径键: %d\n", common)
|
||||
report(" A 有 B 没有: %d\n", aOnly.count)
|
||||
printPathExamples(aOnly, *maxExamples, report)
|
||||
report(" B 有 A 没有: %d\n", bOnly.count)
|
||||
printPathExamples(bOnly, *maxExamples, report)
|
||||
if bySet := sideB.uniquePaths - bOnly.count; bySet != common {
|
||||
report(" !! 交叉校验不一致: 由 B 侧推出的共有数=%d 与 A 侧推出的 %d 不同\n", bySet, common)
|
||||
}
|
||||
|
||||
// ---------- 3. 逐文件比对 ----------
|
||||
report("\n=== 3. 逐文件比对(DataType / Size / 解压后原始字节) ===\n")
|
||||
compareStart := time.Now()
|
||||
stats := compareFiles(sideA.archive, sideB.archive, *limit, *maxExamples)
|
||||
compareElapsed := time.Since(compareStart)
|
||||
stats.common = common
|
||||
|
||||
report(" A 文件表条目: %d(按 File.Index 顺序遍历)\n", sideA.snapshot.FileCount)
|
||||
report(" 命中公共路径: %d\n", stats.common)
|
||||
report(" 实际比对: %d(-limit=%d)\n", stats.compared, *limit)
|
||||
report(" 完全相同: %d\n", stats.identical)
|
||||
report(" dataType 不同: %d\n", stats.dataTypeDiff)
|
||||
report(" 大小不同: %d\n", stats.sizeDiff)
|
||||
report(" 内容不同: %d\n", stats.contentDiff)
|
||||
report(" 读取失败: %d\n", stats.readErrors)
|
||||
// 表顺序是「仅重打包」的另一个观察维度:路径配对看不出条目顺序被换过。
|
||||
report(" 文件表顺序: A/B 下标完全对应=%t(错位 %d 条,逆序 %d 处;只作信息,不参与差异判定)\n",
|
||||
stats.indexMismatch == 0, stats.indexMismatch, stats.orderInversions)
|
||||
printExamples("内容不同示例", stats.contentExamples, stats.contentDiff, *maxExamples, report)
|
||||
printExamples("dataType 不同示例", stats.dataTypeExamples, stats.dataTypeDiff, *maxExamples, report)
|
||||
printExamples("大小不同示例", stats.sizeExamples, stats.sizeDiff, *maxExamples, report)
|
||||
if len(stats.errorSamples) > 0 {
|
||||
report(" 读取失败示例 (最多 %d 条, 共 %d 条):\n", *maxExamples, stats.readErrors)
|
||||
for _, item := range stats.errorSamples {
|
||||
report(" - %s\n", item)
|
||||
}
|
||||
}
|
||||
// ---------- 4. 读取缓存 ----------
|
||||
report("\n=== 4. 读取缓存(佐证 chunk 没有被反复解密解压) ===\n")
|
||||
printCache(sideA, report)
|
||||
printCache(sideB, report)
|
||||
|
||||
report("\n=== 5. 耗时 ===\n")
|
||||
report(" open: A=%.3fs B=%.3fs\n", openA.Seconds(), openB.Seconds())
|
||||
report(" 路径集合: %.3fs\n", pathElapsed.Seconds())
|
||||
report(" 逐文件: %.3fs\n", compareElapsed.Seconds())
|
||||
report(" 合计: %.3fs\n", time.Since(total).Seconds())
|
||||
|
||||
// ---------- 6. 汇总 + 退出码 ----------
|
||||
differences := aOnly.count + bOnly.count + stats.dataTypeDiff + stats.sizeDiff +
|
||||
stats.contentDiff + stats.readErrors + containerDiff
|
||||
truncated := stats.compared < stats.common
|
||||
|
||||
report("\n=== 6. 汇总 ===\n")
|
||||
report(" 公共路径 %d;完全相同 %d;A 独有 %d;B 独有 %d;dataType 不同 %d;大小不同 %d;内容不同 %d;读取失败 %d;容器级差异 %d\n",
|
||||
stats.common, stats.identical, aOnly.count, bOnly.count,
|
||||
stats.dataTypeDiff, stats.sizeDiff, stats.contentDiff, stats.readErrors, containerDiff)
|
||||
if truncated {
|
||||
report(" 注意: -limit=%d 只比对了 %d/%d 条公共路径,结论不覆盖全部文件\n",
|
||||
*limit, stats.compared, stats.common)
|
||||
}
|
||||
|
||||
switch {
|
||||
case differences == 0 && !truncated:
|
||||
fmt.Printf("IDENTICAL: %d files\n", stats.compared)
|
||||
case differences == 0:
|
||||
fmt.Printf("PARTIAL: %d of %d files compared (limit), no differences found\n",
|
||||
stats.compared, stats.common)
|
||||
default:
|
||||
fmt.Printf("DIFFERENCES: files_compared=%d identical=%d datatype=%d size=%d content=%d a_only=%d b_only=%d read_errors=%d container=%d\n",
|
||||
stats.compared, stats.identical, stats.dataTypeDiff, stats.sizeDiff,
|
||||
stats.contentDiff, aOnly.count, bOnly.count, stats.readErrors, containerDiff)
|
||||
}
|
||||
if !*quiet {
|
||||
fmt.Printf("elapsed: %.3fs\n", time.Since(total).Seconds())
|
||||
}
|
||||
|
||||
switch {
|
||||
case differences == 0 && !truncated:
|
||||
os.Exit(0)
|
||||
case differences == 0:
|
||||
os.Exit(3)
|
||||
default:
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// side 保存一侧归档的元信息与已打开的归档句柄。
|
||||
type side struct {
|
||||
label string
|
||||
path string
|
||||
archive *platformpvf.Archive
|
||||
snapshot platformpvf.ArchiveSnapshot
|
||||
uniquePaths int
|
||||
}
|
||||
|
||||
func openSide(label, path string) (*side, time.Duration, error) {
|
||||
start := time.Now()
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
return nil, time.Since(start), err
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
// RangePaths 只遍历读取层已建好的路径索引,不复制文件表、不解析字符串。
|
||||
unique := 0
|
||||
archive.RangePaths(func(string) bool {
|
||||
unique++
|
||||
return true
|
||||
})
|
||||
return &side{
|
||||
label: label,
|
||||
path: path,
|
||||
archive: archive,
|
||||
snapshot: archive.Snapshot(),
|
||||
uniquePaths: unique,
|
||||
}, elapsed, nil
|
||||
}
|
||||
|
||||
// printSide 打印一侧的容器级信息。输出走 report,因此 -quiet 下会连同段落标题一起静默。
|
||||
func printSide(s *side, report func(string, ...any)) {
|
||||
snap := s.snapshot
|
||||
report("[%s] %s\n", s.label, s.path)
|
||||
report(" bytes=%d sha256=%s format=%s can_read_file_data=%t\n",
|
||||
snap.Size, snap.Checksum, snap.Format, s.archive.CanReadFileData())
|
||||
// 读取层只暴露 file_count / group_count;body/hash/name 段的字节数没有公共 API,
|
||||
// 这里如实说明,而不是在本工具里重新实现一遍归档解析(那会引入第二套「事实来源」)。
|
||||
report(" header: file_count=%d group_count=%d (body/hash/name 段字节数未在 platform/pvf 公共 API 暴露)\n",
|
||||
snap.FileCount, snap.GroupCount)
|
||||
report(" unique_path_keys=%d chunk_cache_limit=%d text_cache_limit=%d\n",
|
||||
s.uniquePaths, snap.ChunkCacheLimit, snap.TextCacheLimit)
|
||||
}
|
||||
|
||||
func printCache(s *side, report func(string, ...any)) {
|
||||
snap := s.archive.Snapshot()
|
||||
report(" [%s] cached_chunks=%d cached_chunk_bytes=%d hits=%d misses=%d evictions=%d\n",
|
||||
s.label, snap.CachedChunks, snap.CachedChunkBytes, snap.CacheHits, snap.CacheMisses, snap.CacheEvictions)
|
||||
}
|
||||
|
||||
// pathDiff 记录某一侧独有的路径:只保留最多 maxExamples 条示例,总数始终累计,
|
||||
// 这样即使差集高达百万条也不会把内存吃光。
|
||||
type pathDiff struct {
|
||||
count int
|
||||
examples []string
|
||||
}
|
||||
|
||||
func (d *pathDiff) add(value string) {
|
||||
d.count++
|
||||
if len(d.examples) < cap(d.examples) {
|
||||
d.examples = append(d.examples, value)
|
||||
}
|
||||
}
|
||||
|
||||
func newPathDiff(maxExamples int) pathDiff {
|
||||
return pathDiff{examples: make([]string, 0, maxExamples)}
|
||||
}
|
||||
|
||||
func printPathExamples(diff pathDiff, maxExamples int, report func(string, ...any)) {
|
||||
for _, item := range diff.examples {
|
||||
report(" - %s\n", item)
|
||||
}
|
||||
if diff.count > len(diff.examples) {
|
||||
report(" ... 其余 %d 条未列出(-max-examples=%d)\n", diff.count-len(diff.examples), maxExamples)
|
||||
}
|
||||
}
|
||||
|
||||
// diffPaths 统计两侧路径集合的差集。
|
||||
// A 独有的判定方式是「在 B 的路径索引里查不到」,直接用读取层的 FindFileIndex,
|
||||
// 不自己复制一份路径表(106 万条路径的额外 map 会白白多占几百 MB)。
|
||||
// 示例路径按字典序排序,保证同一份输入每次输出一致,便于脚本化比对。
|
||||
func diffPaths(sideA, sideB *side, maxExamples int) (pathDiff, pathDiff, int) {
|
||||
aOnly := newPathDiff(maxExamples)
|
||||
bOnly := newPathDiff(maxExamples)
|
||||
sideA.archive.RangePaths(func(key string) bool {
|
||||
if sideB.archive.FindFileIndex(key) < 0 {
|
||||
aOnly.add(displayPath(sideA.archive, key))
|
||||
}
|
||||
return true
|
||||
})
|
||||
sideB.archive.RangePaths(func(key string) bool {
|
||||
if sideA.archive.FindFileIndex(key) < 0 {
|
||||
bOnly.add(displayPath(sideB.archive, key))
|
||||
}
|
||||
return true
|
||||
})
|
||||
sort.Strings(aOnly.examples)
|
||||
sort.Strings(bOnly.examples)
|
||||
|
||||
// 共有数由 A 的唯一键数减去 A 独有数得到;调用方再用 B 侧做一次交叉校验。
|
||||
return aOnly, bOnly, sideA.uniquePaths - aOnly.count
|
||||
}
|
||||
|
||||
// displayPath 把归一化小写键换回归档里记录的原始大小写路径,仅对要打印的少数示例调用。
|
||||
func displayPath(archive *platformpvf.Archive, key string) string {
|
||||
if file, ok := archive.FindFile(key); ok && file.ArchivePath != "" {
|
||||
return file.ArchivePath
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// fileExample 是一条逐文件差异的示例记录。
|
||||
type fileExample struct {
|
||||
path string
|
||||
aType int
|
||||
bType int
|
||||
aSize int
|
||||
bSize int
|
||||
note string // 内容差异的补充说明(首个不同字节偏移 / 长度不同)
|
||||
}
|
||||
|
||||
// compareStats 是逐文件比对的分类计数。
|
||||
// 各类计数互相独立:同一个文件可以同时命中 dataType 不同和内容不同,
|
||||
// 这样每类数字都如实反映问题,而不是互相掩盖。
|
||||
type compareStats struct {
|
||||
common int
|
||||
compared int
|
||||
identical int
|
||||
dataTypeDiff int
|
||||
sizeDiff int
|
||||
contentDiff int
|
||||
readErrors int
|
||||
contentExamples []fileExample
|
||||
dataTypeExamples []fileExample
|
||||
sizeExamples []fileExample
|
||||
errorSamples []string
|
||||
|
||||
// 文件表顺序信息(只作信息,不参与差异判定):
|
||||
// 上面的逐文件比对是按路径配对的,看不出重打包有没有把表项顺序换了。
|
||||
// 顺着 A 的 Index 顺序走时,若同名条目的 B 侧下标一路递增,说明 B 的表顺序与 A 一致。
|
||||
indexMismatch int
|
||||
orderInversions int
|
||||
prevBIndex int
|
||||
hasPrevB bool
|
||||
}
|
||||
|
||||
// compareFiles 按 A 的文件表顺序逐条比对共有路径。
|
||||
// limit > 0 时读满 limit 条就停止(公共路径总数由 diffPaths 单独统计),便于快速抽样。
|
||||
func compareFiles(a, b *platformpvf.Archive, limit, maxExamples int) compareStats {
|
||||
var stats compareStats
|
||||
for _, fileA := range a.Files() {
|
||||
// 只比对共有路径;A 独有的路径已经在路径集合一节的差集里统计过了。
|
||||
fileB, ok := b.FindFile(fileA.ArchivePath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stats.common++
|
||||
if stats.hasPrevB && fileB.Index < stats.prevBIndex {
|
||||
stats.orderInversions++
|
||||
}
|
||||
stats.hasPrevB = true
|
||||
stats.prevBIndex = fileB.Index
|
||||
if fileB.Index != fileA.Index {
|
||||
stats.indexMismatch++
|
||||
}
|
||||
if limit > 0 && stats.compared >= limit {
|
||||
break
|
||||
}
|
||||
stats.compared++
|
||||
|
||||
// 两侧都按文件表索引读取:FileRaw 返回的就是该条目的解压后原始字节副本,
|
||||
// 与 chunk 划分、偏移、压缩方式无关,因此它才是内容一致性的证据。
|
||||
rawA, errA := a.FileRaw(fileA.Index)
|
||||
if errA != nil {
|
||||
stats.readErrors++
|
||||
stats.errorSamples = appendCappedString(stats.errorSamples,
|
||||
fmt.Sprintf("%s: A: %v", fileA.ArchivePath, errA), maxExamples)
|
||||
continue
|
||||
}
|
||||
rawB, errB := b.FileRaw(fileB.Index)
|
||||
if errB != nil {
|
||||
stats.readErrors++
|
||||
stats.errorSamples = appendCappedString(stats.errorSamples,
|
||||
fmt.Sprintf("%s: B: %v", fileA.ArchivePath, errB), maxExamples)
|
||||
continue
|
||||
}
|
||||
|
||||
base := fileExample{
|
||||
path: fileA.ArchivePath,
|
||||
aType: fileA.DataType, bType: fileB.DataType,
|
||||
aSize: fileA.Size, bSize: fileB.Size,
|
||||
}
|
||||
same := true
|
||||
if fileA.DataType != fileB.DataType {
|
||||
same = false
|
||||
stats.dataTypeDiff++
|
||||
stats.dataTypeExamples = appendCapped(stats.dataTypeExamples, base, maxExamples)
|
||||
}
|
||||
// Size 既比文件表声明的 dataSize,也比实际取回的字节长度:
|
||||
// 两者本应相等(readRawIndex 按 dataSize 切片),不等就说明读取层与表不一致。
|
||||
if fileA.Size != fileB.Size || len(rawA) != len(rawB) {
|
||||
same = false
|
||||
stats.sizeDiff++
|
||||
stats.sizeExamples = appendCapped(stats.sizeExamples, base, maxExamples)
|
||||
}
|
||||
if !bytes.Equal(rawA, rawB) {
|
||||
same = false
|
||||
stats.contentDiff++
|
||||
content := base
|
||||
if len(rawA) == len(rawB) {
|
||||
content.note = fmt.Sprintf("首差偏移=%d", firstDiffOffset(rawA, rawB))
|
||||
} else {
|
||||
content.note = fmt.Sprintf("长度不同 A=%d B=%d 首差偏移=%d",
|
||||
len(rawA), len(rawB), firstDiffOffset(rawA, rawB))
|
||||
}
|
||||
stats.contentExamples = appendCapped(stats.contentExamples, content, maxExamples)
|
||||
}
|
||||
if same {
|
||||
stats.identical++
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func appendCapped(list []fileExample, item fileExample, maxExamples int) []fileExample {
|
||||
if len(list) >= maxExamples {
|
||||
return list
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
|
||||
func appendCappedString(list []string, item string, maxExamples int) []string {
|
||||
if len(list) >= maxExamples {
|
||||
return list
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
|
||||
// firstDiffOffset 返回两个字节串第一个不同的下标;前缀相同时返回公共长度。
|
||||
func firstDiffOffset(left, right []byte) int {
|
||||
size := len(left)
|
||||
if len(right) < size {
|
||||
size = len(right)
|
||||
}
|
||||
for index := 0; index < size; index++ {
|
||||
if left[index] != right[index] {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func printExamples(title string, examples []fileExample, total, maxExamples int, report func(string, ...any)) {
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
report(" %s (最多 %d 条, 共 %d 条):\n", title, maxExamples, total)
|
||||
for _, item := range examples {
|
||||
note := item.note
|
||||
if note != "" {
|
||||
note = " " + note
|
||||
}
|
||||
report(" - %s A(type=%d size=%d) B(type=%d size=%d)%s\n",
|
||||
item.path, item.aType, item.aSize, item.bType, item.bSize, note)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user