pbai 发表于 4 天前

PBIDEA:用 uo_vfs 做虚拟文件系统全解(目录与 zip 挂成一个盘、写入靠写目录、句柄属性崩溃边界,PB10 基准 · PB12.5 实测)

PBIDEA:用 uo_vfs 做虚拟文件系统全解(目录与 zip 挂成一个盘、写入靠写目录、句柄属性崩溃边界,PB10 基准 · PB12.5 实测)


阅读说明
1. 适用版本:基准 PB10,PB 12.5 实测行为一致(PBIDEA 运行库 PbIdea.dll,快照见文末版本声明)
2. 支持数据库:本文不涉及数据库
3. 操作系统与环境要求:Windows 7+;需随程序部署 PbIdea.dll 与 Pbidea_cs.dll;示例用到的资源包为 ZIP 格式;7z 只用「内容是 ZIP、改名 .7z」的文件做过挂载探测,未用真 7z 包实测成员读取
4. 难度系数:★★☆☆☆
5. 其它阅读说明:示例路径均相对「程序工作目录」,素材放在工作目录下的 pbvfs 子目录(res、out、patch1、patch2、dirx),建目录与压缩包的命令见文末「复现示例的准备工作」;VFS 对相对路径与绝对路径同样接受,正式项目里换成绝对路径即可;已读过 uo_file / uo_zip 篇的读者可跳过基础部分


虚拟文件系统(VFS)这个词容易把人吓住,其实它的用处很朴素:让程序用同一套路径去访问硬盘目录、ZIP 包里的成员,甚至内存里的一个 ZIP blob。写插件、换皮肤、做补丁包、把帮助文档和图片打进一个包里发给客户,这些场景都靠它。PBIDEA 提供的 uo_vfs 就是干这件事的,配两个句柄对象 uo_vfs_reader / uo_vfs_writer 做流式读写。

这篇把 uo_vfs 的 20 个方法、2 个句柄对象的 17 个方法一次讲透,重点放在三件最容易踩的事上:写文件必须先设写目录且路径要相对、同一挂载点的优先级是先挂的赢、句柄属性在句柄无效时会直接把进程打崩。三条全是实机跑出来的,不是看声明猜的。

一、它能挂什么:SupportedArchive 只说了半句

先看类型清单:


uo_vfs lv
string ls_arc[]
long ll_n, i
string ls_out

lv = create uo_vfs
ll_n = lv.SupportedArchive(ls_arc)
ls_out = '支持的归档类型数=' + String(ll_n)
for i = 1 to ll_n
        ls_out = ls_out + Char(13) + Char(10) + '' + ls_arc
next
destroy lv
MessageBox('结果', ls_out)


实测返回 2 个:ZIP、7Z。但别被这个数字骗了——硬盘目录也能挂,只是不在这个清单里。源码注释写的是「取提供类型: ZIP 7z, 以及物理目录」,清单却只列了两个归档类型,目录是默认能力,不参与枚举。

还有一条实测结论值得记住:内容识别不看扩展名。把一个正常 ZIP 改名成 pack7.7z 再挂载,mount() 返回 true、成员照样读得出来。反过来说,指望靠扩展名拦非法包是拦不住的,要判真伪请看 mount() 的返回值和随后 read() 的长度。

二、最小可用:把目录挂成一个盘


public function string of_ex1 ();
        uo_vfs lv
        string ls_out, ls_files[]
        long ll_n, i

        // 1) 建对象
        lv = create uo_vfs

        // 2) 把硬盘目录挂成一个虚拟目录:实体 pbvfs/res → 虚拟 /res
        if not lv.mount('pbvfs/res', '/res') then
                ls_out = '挂载失败:' + lv.vfsLastError()
        else
                // 3) 用虚拟路径读文件(不用再拼实体路径)
                ls_out = 'a.txt = [' + String(lv.read('/res/a.txt'), EncodingANSI!) + ']'

                // 4) 列目录:目录与文件混在同一个数组里,靠名字自己判断
                ll_n = lv.GetFiles('/res', ls_files)
                ls_out = ls_out + Char(13) + Char(10) + '条目数=' + String(ll_n)
                for i = 1 to ll_n
                        ls_out = ls_out + Char(13) + Char(10) + '' + ls_files
                next
        end if

        destroy lv
        MessageBox('结果', ls_out)
end function


跑出来的读数:a.txt = 「A1」,条目数 4,依次是 a.txt、big.txt、pack.zip、sub。

这里用的是相对路径 pbvfs/res(相对进程当前目录)。VFS 对相对与绝对路径一视同仁:实测把同一个目录用 D:/pbvfs/res 这种绝对路径挂上去,行为完全一致。相对路径的好处是程序拷到哪都能跑;绝对路径的好处是不受「双击启动时工作目录变成 exe 目录」影响——生产环境建议先用 GetCurrentDirectory() 取到工作目录再拼,别默认 cwd 就是你想的那个。

四个细节:


[*]read() 返回 blob,不是 string。要变字符串必须显式给编码:String(lb, EncodingANSI!)。不给编码时按 Unicode 解,A1 两个字节会被当成一个汉字,结果是问号——这不是乱码,是解错了码。
[*]GetFiles() 返回条目个数,文件与子目录混在同一个 ref string[] 里,不给类型标志。想区分就自己再探一次 state(),或者改用 JSON 重载(见第七节)。
[*]目录挂载成功后 vfsLastError() 是 no error(0)。
[*]mount() 的虚拟目录不带前导斜杠也能工作('a' 与 '/a' 等效,实测两者读同一个文件都成功),但建议一律带 /,否则和相对路径的写法混起来很难查。


三、挂 ZIP:不解压直接读成员


public function string of_ex2 ();
        uo_vfs lv
        uo_json lj
        string ls_out
        blob lb

        lv = create uo_vfs
        // 直接挂一个 zip:不用先解压,成员当场可读
        lv.mount('pbvfs/res/pack.zip', '/p')

        lb = lv.read('/p/conf/app.json')
        ls_out = 'conf/app.json 长度=' + String(Len(lb))
        ls_out = ls_out + Char(13) + Char(10) + '实体位置=' + lv.GetRealDir('/p/conf/app.json')

        // state 取属性:返回值恒为 false,信息全在 JSON 里
        lj = create uo_json
        lv.state('/p/conf/app.json', lj)
        ls_out = ls_out + Char(13) + Char(10) + lj.ToString()

        destroy lj
        destroy lv
        MessageBox('结果', ls_out)
end function


读数:长度 23,实体位置 pbvfs/res/pack.zip,JSON 是


{"path":"/p/conf/app.json","realpath":"pbvfs/res/pack.zip","filesize":23,"modtime":"...","createtime":"...","filetype":"file","readonly":true}


这里有三个「反直觉」:

其一,GetRealDir() 对压缩包成员返回的是「包文件的实体路径」,不是成员的虚拟路径。 这个语义要想清楚:它回答的是「这个虚拟路径背后落在哪个实体文件上」,ZIP 成员的答案就是那个 ZIP。目录挂载时它返回的是目录路径,两者都只到「容器」为止。

其二,state() 的返回值恒为 false。 文件存在、目录存在、不存在,全是 false。真正的信息在第二个参数那个 uo_json 里。别按「true=成功」的直觉写判断,否则永远走失败分支。不存在的路径填出来的 JSON 是 {"path":"/zzz/x","realpath":"","filesize":-1,"filetype":"other","readonly":true}——filesize 为 -1 才是判据。

其三,ZIP 挂载成功之后 vfsLastError() 会留一条 not found(11)。 目录挂载不会有这条。这是内部探测成员时留下的尾巴,不代表挂载失败。结论:判成败只看 mount() 的返回布尔值,别拿 vfsLastError() 当唯一判据——它是「上一条操作的错误码文本」,粘得很。

四、句柄式读:reader 的 8 个方法

uo_vfs_reader 有 FileName / FileSize / eof / seek / tell / read() / read(long) / DestroyReader() 一共 8 个。DestroyReader() 是 private,外部调不了(编译报 C0109),释放靠 destroy 对象引用或 destroy 掉 vfs。


public function string of_ex3 ();
        uo_vfs lv
        uo_vfs_reader lr
        blob lb
        string ls_out

        lv = create uo_vfs
        lv.mount('pbvfs/res', '/res')

        // 句柄式读:先看大小,再分段读
        lr = lv.CreateReader('/res/big.txt')
        ls_out = '大小=' + String(lr.FileSize())

        lb = lr.read(10)
        ls_out = ls_out + Char(13) + Char(10) + '前 10 字节=[' + String(lb, EncodingANSI!) + '] 位置=' + String(lr.tell())

        lr.seek(0)
        lb = lr.read()
        ls_out = ls_out + Char(13) + Char(10) + '整体重读长度=' + String(Len(lb)) + ' 到末尾=' + String(lr.eof())

        destroy lr
        destroy lv
        MessageBox('结果', ls_out)
end function


读数:大小 26,前 10 字节 abcdefghij、位置 10,seek 回 0 后整体重读长度 26、已经到末尾。

分段读的边界,实测清单如下(4 字节文件上逐条验证):


操作返回值指针说明
read(2)2 字节2正常
read(0)空 blob不动不会当「读到末尾」
seek(0)true0从字节 0 重来
seek(-1)false不变负偏移直接拒绝
seek(99)true99越界 seek 不报错,只把指针摆到 99
越界后 read()空 blob99读到空
eof()读完后 true—判末尾就用它,别用「read 返回空」


seek() 的单位是字节,tell() 也是字节位置,两者和 read(n) 的 n 同一量纲——这点比 PB 原生 FileRead 干净。

ZIP 成员也能开 reader,FileName 返回虚拟路径 /p/y/z.txt、FileSize 返回成员解压后大小、read() 正常。

五、写:必须先设写目录,且路径要相对

这是 uo_vfs 最容易卡住人的地方,也是本文最该记住的一节。先看会崩的写法:挂一个目录,直接往里 CreateWriter 然后 write。

实测:CreateWriter('/a/x.txt') 返回的对象 IsValid 为 true,FileName 正常,但 write() 返回 false,vfsLastError() 说 write directory is not set(13)。此时如果你顺手调一下 lw.tell() 或 lw.FileSize()——进程直接崩,退出码 3221225477(0xC0000005 访问违例),TRY-CATCH 抓不住,日志只留下来 exited before writing a result。

正确姿势只有一条:SetWriteDir() 指定写目录,CreateWriter() 用相对写目录的路径。


public function string of_ex4 ();
        uo_vfs lv
        uo_vfs_writer lw
        string ls_out

        lv = create uo_vfs
        // 写之前必须先指定写目录,否则 write() 一律返回 false
        lv.SetWriteDir('pbvfs/out')
        // 想读回自己写的文件,还得把这个目录也挂进来
        lv.mount('pbvfs/out', '/out')

        lw = lv.CreateWriter('log.txt')
        if not lw.write('第一行' + Char(13) + Char(10)) then
                ls_out = '写入失败:' + lv.vfsLastError()
        else
                ls_out = '写成功,读回=[' + String(lv.read('/out/log.txt'), EncodingANSI!) + ']'
        end if

        destroy lw
        destroy lv
        MessageBox('结果', ls_out)
end function


读数:写成功,读回 「第一行」(后面跟一个回车换行)。

写目录的四条硬规则,逐条都是踩出来的:

规则一:写目录里的文件不会被 VFS 搜到。 只 SetWriteDir('pbvfs/out') 不挂载,GetRealDir('log.txt') 返回空串,CreateReader('log.txt') 返回的对象合法但读不出内容(read() 返回空 blob,FileSize() 更是直接崩)。想让写出去的文件能读回来,必须把这个目录也 mount() 进来,读的时候用挂载点路径。

规则二:写路径带挂载点前缀必败。 CreateWriter('/out/log.txt') 或 CreateWriter('/a/x.txt') 这种写法,write() 返回 false,错误是 not found(11) 或 write directory is not set(13)。写路径只能是相对写目录的:'log.txt'、'sub/log.txt'。

规则三:子目录会自动建。 写 'sub/g3b.txt',磁盘上 w9/sub/g3b.txt 真出现了,不用先建目录。

规则四:挂载的目录本身是只读的。 往挂载点写不会落盘——磁盘上那个目录从头到尾没多出文件。VFS 的写入永远只往写目录落。

写入行为的其余实测读数:


场景结果
覆盖写(先 10 字节 0123456789,再写 AB)磁盘上文件变成 2 字节 AB,会截断
追加 CreateWriter(file, true)'abc' 之后追加 'de',文件 5 字节 abcde
两个 writer 同时打开写两个文件各自正确落盘,互不干扰
flush()返回 true;不 flush 直接 destroy 也会落盘
写字符串 '中文'磁盘 4 字节 D6 D0 CE C4,GBK
写 Blob('中文')磁盘 4 字节 2D 4E 87 65,UTF-16LE(Blob(字符串) 是 UTF-16)


最后两行是个真坑:同样是「写中文」,传字符串和传 blob 落下去的编码完全不同,长度却都是 4 字节,肉眼比对字节数根本发现不了。要 ASCII 兼容文本,一律传字符串;要明确 UTF-8,自己先 Blob(s, EncodingUTF8!) 再写。

写目录设好、路径给对的情况下,writer 的属性全部正常:FileSize=3、tell=3、eof=false、seek(0)=true、flush=true。所以那批崩溃的根因不是「这些方法不能用」,而是句柄背后的文件没打开成功——无效句柄上的属性访问就是裸指针解引用。

六、挂载优先级:先挂的赢,append 参数不管用

同一个虚拟目录挂多个实体,是做「主资源 + 补丁包」的常规手法。帮助注释写的是「append: true 添加到搜索最后,false 插入到搜索最前」,听起来像「默认后挂的优先」。实测完全不是这样。


public function string of_ex5 ();
        uo_vfs lv
        string ls_out

        lv = create uo_vfs

        // 1) 挂一个不存在的目录:返回 false,原因在 vfsLastError()
        if lv.mount('pbvfs/nosuchdir', '/n') then
                ls_out = '意外成功'
        else
                ls_out = '挂不存在目录 → false,原因=' + lv.vfsLastError()
        end if

        // 2) 两个目录挂到同一个虚拟目录:先挂的优先(实测,与帮助注释的 append 说法相反)
        lv.mount('pbvfs/patch1', '/cfg')
        lv.mount('pbvfs/patch2', '/cfg')
        ls_out = ls_out + Char(13) + Char(10) + '先挂的优先 → [' + String(lv.read('/cfg/app.ini'), EncodingANSI!) + ']'

        // 3) UnMount 的参数是「挂载时给的实体路径」,不是虚拟目录;卸掉先挂的,后挂的顶上
        lv.UnMount('pbvfs/patch1')
        ls_out = ls_out + Char(13) + Char(10) + '卸掉 patch1 → [' + String(lv.read('/cfg/app.ini'), EncodingANSI!) + ']'

        destroy lv
        MessageBox('结果', ls_out)
end function


patch1/app.ini 的内容是 v=1,patch2/app.ini 是 v=2。读数:先挂的优先 → 「v=1」,卸掉 patch1 → 「v=2」。

四组对照实验(都验证过):


挂载顺序与参数读到结论
patch1 → patch2(默认)v=1先挂的赢
patch1 → patch2(第二次 append=true)v=1append 不改变顺序
patch1 → patch2(两次都 append=true)v=1同上
patch2 → patch1(默认)v=2换顺序结果跟着换,确认是「先挂优先」


zip 与目录混挂也一样:zip 先挂读到 ZIPX,目录先挂读到 DIRX。所以「补丁覆盖主资源」要反着写:把补丁先挂、主资源后挂,同名文件才是补丁赢;或者干脆只挂补丁目录,让主资源通过 SetRoot 组合。

UnMount() 的参数也容易搞错:传实体路径(挂载时第一个参数),传虚拟目录 /cfg 会返回 false 并报 not mounted(10)。blob 挂载的情况更特殊——mount(blob, '/b') 返回的不是布尔值而是字符串 archive_2786b8ca4834e94e(一个自动生成的归档名),这个字符串就是之后 UnMount() / SetRoot() 的钥匙。

七、GetFiles 的 JSON 重载:一次拿全属性

GetFiles 有两个重载,一个 ref string[],一个 ref uo_json。后者信息量大得多:


public function string of_ex6 ();
        uo_vfs lv
        uo_json lj
        long ll_n
        string ls_out

        lv = create uo_vfs
        lv.mount('pbvfs/res/pack.zip', '/p')

        // GetFiles 的 JSON 重载:一次拿到名字、大小、时间、类型、只读标志
        lj = create uo_json
        ll_n = lv.GetFiles('/p', lj)
        ls_out = '条目数=' + String(ll_n) + Char(13) + Char(10) + lj.ToString()

        destroy lj
        destroy lv
        MessageBox('结果', ls_out)
end function


结构化结果长这样(ZIP 根目录,两个条目):


{"path":"/p","count":2,"items":[
{"name":"conf","realpath":"pbvfs/res/pack.zip","filesize":0,"modtime":"1970-01-01 08:00:00","createtime":"1970-01-01 08:00:00","filetype":"dir","readonly":true},
{"name":"readme.txt","realpath":"pbvfs/res/pack.zip","filesize":11,"modtime":"...","createtime":"...","filetype":"file","readonly":true}]}


要点:


[*]顶层是 path / count / items,跟 ref string[] 版本只给名字不同,这里每个条目自带 filetype(file / dir),终于能区分文件与目录了;
[*]ZIP 成员一律 readonly:true——这也从侧面印证了「归档挂载是只读的」;
[*]ZIP 里的目录项时间戳是 1970-01-01 08:00:00(Unix 纪元),因为 ZIP 条目没存目录时间。按时间排序时要先把这类哨兵值剔掉,否则目录永远排在最前;
[*]GetFiles('/') 返回 1,虚拟根只列挂载点名字,不是各挂载点的内容;
[*]GetFiles('/a/..') 这类越界路径不做安全校验,会返回 7 个条目(实际内容无意义)。目录列举这一路别用它做校验,靠返回值不可信。


八、错误、路径安全与只读边界

vfsLastError() 是唯一的错误文本来源,返回带编号的中文括号形式,实测见过这几个:


文本触发场景
no error(0)上一条操作成功
not found(11)挂不存在的目录 / 读不存在的文件 / 写路径解析不到挂载点
not mounted(10)UnMount() 传了虚拟目录而不是实体路径
write directory is not set(13)没 SetWriteDir 就往挂载点写
permission denied(21)把目录当文件 read()
filename is illegal or insecure(23)路径里有 ..、./ 或反斜杠


路径安全这一条值得单列,因为它比想象中严:


[*]read('/a/../a.txt') → 空 blob + filename is illegal or insecure(23)
[*]read('/a/./a.txt') → 同样被拒(连 ./ 都不放过)
[*]read('\\a\\a.txt') → 同样被拒。虚拟路径一律用正斜杠,从 Windows 实体路径复制过来忘了换斜杠,得到的就是空内容和 23 号错误
[*]反例:read('/b//a.txt')(双斜杠)反而正常,mountPath 带尾斜杠 /b/ 也能正常读


其余边界:


场景结果
跨挂载点找文件不串。/x 挂目录、/y 挂 zip,读 /x/x.txt 得空 blob,不会去 /y 里找
read() 不存在的文件空 blob + not found(11),不抛异常
read() 一个目录空 blob + permission denied(21)
往 ZIP 挂载点写CreateWriter 返回合法对象,write() 返回 false,错误 13
SetRoot('pack.zip','/y')之后 /p/z.txt 就能读到 y/z.txt,把子目录提成根
destroy vfs 与 destroy writer 顺序两种顺序都安全,不会崩
ZIP 里的中文名成员'中文.txt' 可读可列,GetFiles 返回的也是 6 个字符的正常名字


九、★★崩溃边界:无效句柄上的属性访问

这一节是全篇最有价值的部分,因为它花了最多实测时间,也是唯一会「没有任何报错、进程直接消失」的地方。

reader 侧:CreateReader('不存在的路径') 不返回 NULL,IsValid() 是 true,FileName 正常回显,read() 安全返回空 blob。但只要调一次 FileSize()——访问违例,进程退出码 3221225477。同理 tell() / eof() 也不要碰。

writer 侧:CreateWriter(路径) 只要路径解析不到可写位置(没设写目录、带挂载点前缀、挂载点不存在),write() 返回 false,此时 FileSize / tell / eof / seek / flush 五个方法全部崩。

把两边的判据总结成一句可执行的话:


每次 CreateReader / CreateWriter 之后,先看 IsValid(),再调一次 read()(reader)或 write()(writer)看返回值,两个都过了才允许访问属性方法;IsValid() 为 true 不代表句柄可用。


推荐的自保写法:


uo_vfs lv
uo_vfs_reader lr
blob lb
boolean lb_ok

lv = create uo_vfs
lv.mount('pbvfs/res', '/res')
lr = lv.CreateReader('/res/maybe.txt')
lb_ok = false
if IsValid(lr) then
        lb = lr.read()                  // 先读,读的返回值不会崩
        if lv.vfsLastError() = 'no error(0)' then
                lb_ok = true
        end if
end if
if lb_ok then
        // 到这里再问 FileSize()/tell() 才安全
        lb_ok = (lr.FileSize() >= 0)
end if
destroy lr
destroy lv


这段没有实测跑(它依赖「读成功后再问属性」这个已实测的前提,逻辑本身是纯判断),标注为静态核对。真正的硬结论是上一节那两条:无效句柄调属性必崩,且崩溃不可捕获。

顺带一个经验:这类崩溃在自动化测试里表现为 exited before writing a result (exit code 3221225477),和你写错代码的报错长得完全不一样,看到这个码先怀疑句柄,别怀疑语法。

十、和 uo_zip / uo_7z、uo_file、uo_dir 怎么分工


需求用什么说明
打包/解压归档到磁盘uo_zip / uo_7z真解压、真压缩,落盘产物完整
直接读归档里的成员,不解压uo_vfs只读、按需读,适合资源包
内存里拼一个 zip 再整体挂上uo_vfs.mount(blob, ...)返回 archive_xxx 名,UnMount 靠它
普通单文件读写uo_file文本/ blob 直读直写、加密、占用查询
目录枚举与批量建删uo_diruo_vfs.GetFiles 只给名字,不递归


一句话选型:「文件要落盘、要改」用 uo_zip / uo_file;「文件只读、想藏起来、想热替换」用 uo_vfs。

另外把代价说清楚,免得用错地方。VFS 的读不是零成本:目录挂载每次 read() 都是一次真实文件 IO,没有缓存层;ZIP 挂载按需只解压被读的那一段,首读比直接读磁盘慢一点,但省掉了整包解压的时间和磁盘占用。所以它适合「包大、一次只读几个文件」的场景(帮助文档、图标、模板、语音包),不适合「把 VFS 当数据库反复扫全包」——那种用法不如先解压到临时目录再走文件系统。写这一侧也要注意:写目录只能有一个,多个业务模块往同一个写目录写时,文件名冲突得自己用子目录隔开(子目录会自动建)。

十一、实战套路:主资源 + 补丁 + 输出三层

把前面所有规则拼起来,就是一个能直接用的结构:


== 目录结构 ==
app/main      主资源(图片、脚本、模板)
app/patch       补丁目录(只放被改过的文件)
app/out         运行时输出(日志、导出)



== 挂载顺序(注意:先挂的优先)==
lv.SetWriteDir('app/out')
lv.mount('app/out',   '/app/out')   // 输出:只为读回
lv.mount('app/patch', '/app/res')   // 补丁先挂 → 同名文件补丁赢
lv.mount('app/main','/app/res')   // 主资源后挂


三条落地建议:


[*]读一律走虚拟路径(/app/res/xxx),程序里不要再出现实体路径,换打包方式时只改三行 mount;
[*]写一律相对写目录('log/2026.log'),子目录会自动建,不要自己拼 app/out/...;
[*]判断「文件在不在」不要用 CreateReader——它对不存在的文件也返回合法对象;用 GetFiles 列一遍,或者 read() 后看长度和 vfsLastError()。


变体:用 SetRoot 把子目录提成根。 有时候包里是「按版本分目录」的结构(v1/、v2/),程序只想看见当前版本那一层。SetRoot(归档, 子目录) 就是干这个的:实测把 pack.zip 挂到 /p 之后,SetRoot('pbvfs/res/pack.zip','/y') 返回 true,随后 read('/p/z.txt') 直接读到了包里 y/z.txt 的 4 个字节——根被换掉了,虚拟路径不用跟着变。这样切版本只需要改一行 SetRoot,调用方的路径一字不动。注意它的第一个参数同样是「挂载时给的实体路径」,和 UnMount() 一致,拿 blob 挂载时则用 mount() 返回的那个 archive_xxx。

变体:内存里的 ZIP 直接挂。 mount(blob, '/b') 的重载省掉了落盘:从网络下载、从数据库 blob 字段取出、从内存里动态拼出来的 ZIP,都能直接挂上就读。代价是这个重载返回字符串而不是布尔值(自动生成的 archive_xxx 名),判成败的方式也就变了——名字非空即成功,UnMount() 时要用这个名字。

十二、PB12.5 差异

无差异。 全部探针在 PB10 与 PB12.5 两条轨上各跑一遍,读数逐条一致:SupportedArchive 都是 ZIP/7Z 两项、state() 都恒返回 false、写目录规则相同、无效句柄崩溃的退出码相同(都是 3221225477)、挂载优先级都是先挂优先。口径:PB10 基准 · PB12.5 实测。

十三、坑清单(16 条)


[*]read() 返回 blob,String(lb) 不给编码会按 Unicode 解,ASCII 变问号;
[*]SupportedArchive 只列 ZIP/7Z,目录能挂但不在清单里;
[*]ZIP 挂载成功仍会留下 not found(11),判成败看 mount() 返回值;
[*]state() 恒返回 false,判据是 JSON 里的 filesize(不存在为 -1);
[*]GetRealDir() 对 ZIP 成员返回包文件路径,不是成员路径;
[*]CreateReader 对不存在的文件返回合法对象;
[*]无效句柄上调 FileSize/tell/eof/seek/flush 直接崩进程(0xC0000005,不可捕获);
[*]写之前必须 SetWriteDir,否则 write() 恒 false;
[*]写路径只能是相对写目录的,带挂载点前缀必败;
[*]写目录里的文件不会被 VFS 搜到,想读回必须把该目录也挂上;
[*]挂载的目录是只读的,往里写不落盘;
[*]同一挂载点先挂的优先,append=true 不改变顺序(与帮助注释说法相反);
[*]UnMount() 传实体路径,传虚拟目录报 not mounted(10);blob 挂载传返回的 archive_xxx;
[*]虚拟路径禁用反斜杠、..、./,一律返回 filename is illegal or insecure(23);
[*]覆盖写会截断,追加要 CreateWriter(file, true),子目录自动创建;
[*]写字符串是 GBK、写 Blob(字符串) 是 UTF-16LE,字节数可能相同,别靠长度判断编码。


十四、复现示例的准备工作

示例以「工作目录下的 pbvfs」为素材根(想放别处,把代码里的 pbvfs/... 换成绝对路径即可),一条命令建好目录:


md pbvfs\res\sub, pbvfs\out, pbvfs\patch1, pbvfs\patch2, pbvfs\dirx
"A1" | Set-Content -Encoding ascii pbvfs/res/a.txt
"B2B2" | Set-Content -Encoding ascii pbvfs/res/sub/b.txt
"abcdefghijklmnopqrstuvwxyz" | Set-Content -Encoding ascii pbvfs/res/big.txt
"v=1" | Set-Content -Encoding ascii pbvfs/patch1/app.ini
"v=2" | Set-Content -Encoding ascii pbvfs/patch2/app.ini
"DIRX" | Set-Content -Encoding ascii pbvfs/dirx/x.txt


pack.zip 用任意工具压一个进去,内含 conf/app.json(内容 {"name":"demo","ver":1})与 readme.txt 即可;z2.zip 与 zipx.zip 只在优先级实验里用到,内含一个 x.txt 就够。

十五、验证与版本声明


[*]版本声明:版本源目录 D:/PBAI/UPDATE/10(PB10)与 D:/PBAI/UPDATE/125(PB 12.5);核验日期 2026-11-09;运行库 PbIdea.dll 字节数 18,318,336、PE 导出函数数 2,611、md5 25ac290414abc482178b8a6bbd44779c。版本会持续更新,字节数与 md5 随补丁漂移属正常,判版本请解析 PE 导出表,不要看目录名或体积。
[*]双轨四绿:PB10 与 PB12.5 各一遍——导入 0 错误、全量重建 0 错误、PB 运行时跑验证用例 PASS、静态检查 0 错 0 提醒 + 编码检查全 OK。
[*]文中 6 个示例方法均实机跑通,读数与正文一致;第九节的「自保写法」按上文标注为静态核对(未单独跑)。
[*]文中全部结论来自 60 余个独立探针对象(每个探针单独编译、单独运行,崩溃型用例一例一次隔离跑),PB10 兼容版 PBL 与自验证用例见本帖附件。

pbai 发表于 4 天前

本帖最后由 pbai 于 2026-11-10 06:26 编辑

PB10 兼容版 PBL 示例包(nvo_vfsdemo)

本帖文中 6 段示例代码已打包成 PB10 兼容版对象库,随附件发布,可直接 Import 或挂库运行。

一、包内文件

[*]vfs93.pbl —— PB10 兼容版库,内含 1 个对象 nvo_vfsdemo(6 个方法 of_ex1 ~ of_ex6,与文中示例一一对应)
[*]vfs93.pbt —— 配套工程文件(库路径已改为相对路径,解包即可用)
[*]src/nvo_vfsdemo.sru —— 对象源码(GBK / CRLF / 无 BOM)
[*]tests/t_vfsdemo.pbtest —— 自验证用例,一次跑完 6 个示例并输出期望读数
[*]说明.txt —— 环境要求、部署步骤、调用片段、常见坑与实机验证记录


二、环境要求

[*]PowerBuilder 10 为基准版本构建,PB 10 / 10.5 / 11.x / 12.x 均可载入
[*]需 PBIDEA 运行库 PbIdea.dll 与 Pbidea_cs.dll,放在可执行文件同目录(32 位进程用 32 位 DLL)
[*]uo_vfs / uo_vfs_reader / uo_vfs_writer / uo_json 的类型声明在 websuite.pbl 里,须把对应 PB 大版本的 websuite.pbl 挂进库搜索路径(PB10 与 PB12.5 的库格式不通用)
[*]操作系统 Windows 7 及以上;本示例不依赖数据库,因此不涉及建表 SQL


三、操作步骤

[*]解包到可写目录(路径不要带空格)
[*]把 PbIdea.dll、Pbidea_cs.dll 放到测试程序同目录
[*]把 vfs93.pbl 加进库搜索路径,或把 src/nvo_vfsdemo.sru 直接 Import 进自己的 PBL
[*]在工作目录下准备素材目录 pbvfs(res/a.txt = A1、res/big.txt = 26 个小写字母、res/sub/b.txt = B2B2、res/pack.zip 内含 conf/app.json 与 readme.txt、out/ 空目录、patch1/app.ini = v=1、patch2/app.ini = v=2),结构详见包内说明.txt
[*]调用示例:nvo_vfsdemo lnv / lnv = create nvo_vfsdemo / MessageBox('结果', lnv.of_ex1()) / destroy lnv;其余方法 of_ex2 ~ of_ex6 分别演示挂 zip 读成员、句柄分段读、写入写目录再读回、错误与挂载优先级、GetFiles 的 JSON 重载


四、自验证方法
把工作目录切到解包目录的上一级(保证 pbvfs 可见),执行 tests/t_vfsdemo.pbtest 中的 test_vfsdemo(),用例会把 6 个方法的返回值写进 g93_vfsdemo.txt。期望读数(与开发机逐字一致):

EX1|a.txt = / 条目数=4(a.txt、big.txt、pack.zip、sub)
EX2|conf/app.json 长度=23,realpath=pbvfs/res/pack.zip
EX3|大小=26,前 10 字节= 位置=10,整体重读长度=26 到末尾=true
EX4|写成功,读回=[第一行]
EX5|挂不存在目录 → false,原因=not found(11);先挂的优先 → ;卸掉 patch1 →
EX6|条目数=2(conf 目录项 + readme.txt 文件项)
PASS

(EX2 / EX6 里的 realpath 取决于你把 pbvfs 放在哪;modtime 是运行时刻,不要求一致。)

五、实机验证情况(如实记录)

[*]PB10 轨:交付包内的 vfs93.pbl 用 PB10 全量重建 0 错误,对象齐全,6 个示例全部跑通,读数与上面期望值逐字一致
[*]PB12.5 轨:用包内 src 源码在 PB 12.5 下新建库导入 + 全量重建 0 错误,6 个示例全部跑通,读数与 PB10 轨逐字一致(本对象两版无行为差异)
[*]静态检查:导入前自检 0 错 0 提醒,源码编码 GBK / CRLF / 无 BOM 校验通过
[*]未实测项:真实 7z 归档(本机无 7z 打包工具,只用「内容是 ZIP、改名 .7z」的文件验证过挂载路径);SetRoot 只验证了「设根后按原虚拟路径读到子目录文件」这一条主线


六、最容易踩的三个坑(包内说明.txt 有完整版)

[*]写文件必须先 SetWriteDir,且 CreateWriter 用「相对写目录」的路径;带挂载点前缀(如 '/out/x.txt')时 write() 恒返回 false
[*]CreateReader 对不存在的文件也返回 IsValid=true 的对象,此时调 FileSize()/tell()/eof() 会直接把进程打崩(访问违例,TRY-CATCH 抓不住);先 read() 看结果,再决定要不要访问属性方法
[*]同一挂载点挂多个实体是「先挂的优先」,UnMount(实体路径) 之后后挂的才生效——做补丁覆盖要反着排


附件:
页: [1]
查看完整版本: PBIDEA:用 uo_vfs 做虚拟文件系统全解(目录与 zip 挂成一个盘、写入靠写目录、句柄属性崩溃边界,PB10 基准 · PB12.5 实测)

免责声明:
本站所发布的一切破解补丁、注册机和注册信息及软件的解密分析文章仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。

Mail To:Admin@SybaseBbs.com